From 4006fe5c6c5d9224d123fc9bbab52c87d0f61e60 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:51:37 -0600 Subject: [PATCH] Route kebab Customize through the prefilled policy wizard so existing methods and values can be renamed, reordered, and kept in that field order on the card after Finalize. Co-authored-by: Cursor --- .../CustomMethodCardWizard.container.tsx | 241 ++++- .../CustomMethodCardWizard.types.ts | 8 +- .../CustomMethodCardWizard.view.tsx | 11 +- ...omMethodCardWizardBlocksList.container.tsx | 25 +- .../CustomMethodCardWizardBlocksList.types.ts | 2 + .../CustomMethodCardWizardBlocksList.view.tsx | 25 +- .../components/FinalReviewChipEditModal.tsx | 885 +++++++++--------- .../card/CommunicationMethodsScreen.tsx | 369 ++++---- .../screens/card/ConflictManagementScreen.tsx | 367 ++++---- .../screens/card/MembershipMethodsScreen.tsx | 367 ++++---- .../screens/review/FinalReviewScreen.tsx | 8 +- .../right-rail/DecisionApproachesScreen.tsx | 367 ++++---- .../screens/select/CoreValuesSelectScreen.tsx | 235 ++++- app/(app)/create/types.ts | 1 + .../utils/coreValueDetailsLocalStorage.ts | 8 +- lib/create/applyFinalReviewChipEditPatch.ts | 1 - lib/create/coreValueChipFacet.ts | 21 + lib/create/customMethodCardWizardConstants.ts | 8 +- lib/create/methodCardWizardPrefill.ts | 391 ++++++++ lib/create/usesWizardFieldBlocksModalBody.ts | 10 +- lib/server/validation/createFlowSchemas.ts | 1 + .../customRule/customMethodCardWizard.json | 1 + ...unicationMethodsScreenPersistence.test.tsx | 304 ++++-- .../CoreValuesSelectScreen.test.tsx | 191 +++- .../CustomMethodCardWizardBlocksList.test.tsx | 113 +++ tests/components/FinalReviewPage.test.tsx | 253 +++-- tests/pages/communication-methods.test.jsx | 49 +- tests/pages/decision-approaches.test.jsx | 5 +- .../applyFinalReviewChipEditPatch.test.ts | 35 + tests/unit/methodCardWizardPrefill.test.ts | 278 ++++++ .../usesWizardFieldBlocksModalBody.test.ts | 12 + 31 files changed, 3062 insertions(+), 1530 deletions(-) create mode 100644 lib/create/methodCardWizardPrefill.ts create mode 100644 tests/components/CustomMethodCardWizardBlocksList.test.tsx create mode 100644 tests/unit/methodCardWizardPrefill.test.ts diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx index fabdee3..e745f6d 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx @@ -5,8 +5,12 @@ import { useMessages, useTranslation, } from "../../../../contexts/MessagesContext"; +import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; -import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants"; +import { + CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS, + 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"; @@ -17,12 +21,13 @@ import type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types * `20066:14748`, `20094:48551`, `20066:14361`). */ const CustomMethodCardWizardContainer = memo( - ({ isOpen, onClose, onFinalize, onPersistCustomUploadFile }) => { + ({ isOpen, onClose, onFinalize, onPersistCustomUploadFile, initialValues }) => { const m = useMessages(); const t = useTranslation("common"); const tUpload = useTranslation("create.upload"); const w = m.create.customRule.customMethodCardWizard; const menuCopy = m.create.customRule.modalKebabMenu; + const { requestConfirm, confirmDialog } = useAsyncConfirm(); const copy = useMemo( () => ({ @@ -68,6 +73,7 @@ const CustomMethodCardWizardContainer = memo( const [draftFieldBlocks, setDraftFieldBlocks] = useState< CustomMethodCardFieldBlock[] >([]); + const [editingBlockId, setEditingBlockId] = useState(null); const [textBlockTitle, setTextBlockTitle] = useState(""); const [textPlaceholderBody, setTextPlaceholderBody] = useState(""); @@ -88,6 +94,14 @@ const CustomMethodCardWizardContainer = memo( const [proportionDefault, setProportionDefault] = useState(50); const fileInputRef = useRef(null); + const initialValuesRef = useRef(initialValues); + initialValuesRef.current = initialValues; + const openSnapshotRef = useRef<{ + title: string; + description: string; + fieldBlocks: string; + } | null>(null); + const fieldModalSnapshotRef = useRef(null); const resetFieldTypeDrafts = useCallback(() => { setTextBlockTitle(""); @@ -112,21 +126,112 @@ const CustomMethodCardWizardContainer = memo( setPolicyDescription(""); setAddFieldExpanded(false); setFieldTypeModal(null); + setEditingBlockId(null); setDraftFieldBlocks([]); + openSnapshotRef.current = null; + fieldModalSnapshotRef.current = null; resetFieldTypeDrafts(); }, [resetFieldTypeDrafts]); useEffect(() => { if (!isOpen) { reset(); + return; } - }, [isOpen, reset]); + const init = initialValuesRef.current; + setWizardStep(1); + setPolicyTitle(init?.title ?? ""); + setPolicyDescription(init?.description ?? ""); + setAddFieldExpanded(false); + setFieldTypeModal(null); + setEditingBlockId(null); + setDraftFieldBlocks( + init?.fieldBlocks ? structuredClone(init.fieldBlocks) : [], + ); + openSnapshotRef.current = { + title: init?.title ?? "", + description: init?.description ?? "", + fieldBlocks: JSON.stringify(init?.fieldBlocks ?? []), + }; + fieldModalSnapshotRef.current = null; + resetFieldTypeDrafts(); + }, [isOpen, reset, resetFieldTypeDrafts]); const dismiss = useCallback(() => { reset(); onClose(); }, [onClose, reset]); + const fieldModalDraftSignature = useCallback(() => { + return JSON.stringify({ + fieldTypeModal, + textBlockTitle, + textPlaceholderBody, + badgeBlockTitle, + badgeOptions, + uploadBlockTitle, + uploadFileName, + uploadAssetUrl, + proportionBlockTitle, + proportionDefault, + }); + }, [ + badgeBlockTitle, + badgeOptions, + fieldTypeModal, + proportionBlockTitle, + proportionDefault, + textBlockTitle, + textPlaceholderBody, + uploadAssetUrl, + uploadBlockTitle, + uploadFileName, + ]); + + const isWizardSessionDirty = useCallback(() => { + const snap = openSnapshotRef.current; + if (!snap) return false; + if (policyTitle !== snap.title || policyDescription !== snap.description) { + return true; + } + if (JSON.stringify(draftFieldBlocks) !== snap.fieldBlocks) { + return true; + } + if ( + fieldTypeModal && + fieldModalSnapshotRef.current != null && + fieldModalDraftSignature() !== fieldModalSnapshotRef.current + ) { + return true; + } + return false; + }, [ + draftFieldBlocks, + fieldModalDraftSignature, + fieldTypeModal, + policyDescription, + policyTitle, + ]); + + const confirmAbandonWizardEdits = useCallback(async () => { + if (!isWizardSessionDirty()) { + return true; + } + return requestConfirm({ + title: menuCopy.discardUnsavedCustomizeChangesTitle, + description: menuCopy.discardUnsavedCustomizeChangesDescription, + proceedText: menuCopy.discardUnsavedCustomizeChangesProceed, + cancelText: menuCopy.discardUnsavedCustomizeChangesCancel, + }); + }, [ + isWizardSessionDirty, + menuCopy.discardUnsavedCustomizeChangesCancel, + menuCopy.discardUnsavedCustomizeChangesDescription, + menuCopy.discardUnsavedCustomizeChangesProceed, + menuCopy.discardUnsavedCustomizeChangesTitle, + requestConfirm, + ]); + const titleTrim = policyTitle.trim(); const descriptionTrim = policyDescription.trim(); @@ -136,7 +241,7 @@ const CustomMethodCardWizardContainer = memo( titleTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS; const descriptionOk = descriptionTrim.length > 0 && - descriptionTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS; + descriptionTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS; if (wizardStep === 1) return titleOk; if (wizardStep === 2) return descriptionOk; return titleOk && descriptionOk; @@ -213,7 +318,9 @@ const CustomMethodCardWizardContainer = memo( const shellDescription = fieldModalHeader?.description ?? headerDescription; const nextLabel = fieldTypeModal - ? copy.fieldModals.addField + ? editingBlockId + ? copy.fieldModals.saveField + : copy.fieldModals.addField : wizardStep === 3 ? copy.footerFinalize : t("buttons.next"); @@ -222,33 +329,124 @@ const CustomMethodCardWizardContainer = memo( ? !fieldModalStepValid : !stepValid; - const handleShellClose = useCallback(() => { + const handleShellClose = useCallback(async () => { if (fieldTypeModal) { + if ( + fieldModalSnapshotRef.current != null && + fieldModalDraftSignature() !== fieldModalSnapshotRef.current && + !(await confirmAbandonWizardEdits()) + ) { + return; + } setFieldTypeModal(null); + setEditingBlockId(null); + fieldModalSnapshotRef.current = null; + return; + } + if (!(await confirmAbandonWizardEdits())) { return; } dismiss(); - }, [dismiss, fieldTypeModal]); + }, [ + confirmAbandonWizardEdits, + dismiss, + fieldModalDraftSignature, + fieldTypeModal, + ]); const kebabMenuItems = useMemo(() => [], []); - const handleBack = useCallback(() => { + const handleBack = useCallback(async () => { if (fieldTypeModal) { + if ( + fieldModalSnapshotRef.current != null && + fieldModalDraftSignature() !== fieldModalSnapshotRef.current && + !(await confirmAbandonWizardEdits()) + ) { + return; + } setFieldTypeModal(null); + setEditingBlockId(null); + fieldModalSnapshotRef.current = null; return; } if (wizardStep === 1) { + if (!(await confirmAbandonWizardEdits())) { + return; + } dismiss(); return; } setWizardStep((s) => (s === 2 ? 1 : 2)); - }, [dismiss, fieldTypeModal, wizardStep]); + }, [ + confirmAbandonWizardEdits, + dismiss, + fieldModalDraftSignature, + fieldTypeModal, + wizardStep, + ]); const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => { + setEditingBlockId(null); resetFieldTypeDrafts(); setFieldTypeModal(ft); + fieldModalSnapshotRef.current = JSON.stringify({ + fieldTypeModal: ft, + textBlockTitle: "", + textPlaceholderBody: "", + badgeBlockTitle: "", + badgeOptions: [], + uploadBlockTitle: "", + uploadFileName: undefined, + uploadAssetUrl: undefined, + proportionBlockTitle: "", + proportionDefault: 50, + }); }, [resetFieldTypeDrafts]); + const handleEditFieldBlock = useCallback( + (block: CustomMethodCardFieldBlock) => { + resetFieldTypeDrafts(); + setEditingBlockId(block.id); + setAddFieldExpanded(false); + switch (block.kind) { + case "text": + setTextBlockTitle(block.blockTitle); + setTextPlaceholderBody(block.placeholderText); + break; + case "badges": + setBadgeBlockTitle(block.blockTitle); + setBadgeOptions([...block.options]); + break; + case "upload": + setUploadBlockTitle(block.blockTitle); + setUploadFileName(block.fileName); + setUploadAssetUrl(block.assetUrl); + break; + case "proportion": + setProportionBlockTitle(block.blockTitle); + setProportionDefault(block.defaultPercent); + break; + } + setFieldTypeModal(block.kind); + fieldModalSnapshotRef.current = JSON.stringify({ + fieldTypeModal: block.kind, + textBlockTitle: block.kind === "text" ? block.blockTitle : "", + textPlaceholderBody: + block.kind === "text" ? block.placeholderText : "", + badgeBlockTitle: block.kind === "badges" ? block.blockTitle : "", + badgeOptions: block.kind === "badges" ? [...block.options] : [], + uploadBlockTitle: block.kind === "upload" ? block.blockTitle : "", + uploadFileName: block.kind === "upload" ? block.fileName : undefined, + uploadAssetUrl: block.kind === "upload" ? block.assetUrl : undefined, + proportionBlockTitle: + block.kind === "proportion" ? block.blockTitle : "", + proportionDefault: block.kind === "proportion" ? block.defaultPercent : 50, + }); + }, + [resetFieldTypeDrafts], + ); + const handleFileChosen = useCallback( async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -285,9 +483,9 @@ const CustomMethodCardWizardContainer = memo( ); }, []); - const appendFieldBlock = useCallback(() => { + const commitFieldBlock = useCallback(() => { if (!fieldTypeModal || !fieldModalStepValid) return; - const id = crypto.randomUUID(); + const id = editingBlockId ?? crypto.randomUUID(); let block: CustomMethodCardFieldBlock; switch (fieldTypeModal) { case "text": @@ -325,11 +523,19 @@ const CustomMethodCardWizardContainer = memo( defaultPercent: proportionDefault, }; } - setDraftFieldBlocks((prev) => [...prev, block]); + setDraftFieldBlocks((prev) => { + if (editingBlockId) { + return prev.map((row) => (row.id === editingBlockId ? block : row)); + } + return [...prev, block]; + }); setFieldTypeModal(null); + setEditingBlockId(null); + fieldModalSnapshotRef.current = null; }, [ badgeBlockTitle, badgeOptions, + editingBlockId, fieldModalStepValid, fieldTypeModal, proportionBlockTitle, @@ -343,7 +549,7 @@ const CustomMethodCardWizardContainer = memo( const handleNext = useCallback(() => { if (fieldTypeModal) { - appendFieldBlock(); + commitFieldBlock(); return; } if (!stepValid) return; @@ -358,7 +564,7 @@ const CustomMethodCardWizardContainer = memo( } setWizardStep((s) => (s === 1 ? 2 : 3)); }, [ - appendFieldBlock, + commitFieldBlock, descriptionTrim, dismiss, draftFieldBlocks, @@ -370,6 +576,7 @@ const CustomMethodCardWizardContainer = memo( ]); return ( + <> ( policyDescription={policyDescription} addFieldExpanded={addFieldExpanded} copy={copy} - maxChars={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS} + maxTitleChars={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS} + maxDescriptionChars={CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS} onPolicyTitleChange={setPolicyTitle} onPolicyDescriptionChange={setPolicyDescription} onPressAddCustomField={() => setAddFieldExpanded(true)} @@ -420,10 +628,13 @@ const CustomMethodCardWizardContainer = memo( stepper={!fieldTypeModal} draftFieldBlocks={draftFieldBlocks} onDraftFieldBlocksReorder={setDraftFieldBlocks} + onEditFieldBlock={handleEditFieldBlock} kebabMoreOptionsAriaLabel={menuCopy.triggerAriaLabel} kebabMenuAriaLabel={menuCopy.menuAriaLabel} kebabMenuItems={kebabMenuItems} /> + {confirmDialog} + ); }, ); diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.types.ts b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.types.ts index ee618dc..b436136 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.types.ts +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.types.ts @@ -2,6 +2,7 @@ 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"; +import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; export interface CustomMethodCardWizardFieldBodiesCopy { requiredHint: string; @@ -47,6 +48,7 @@ export interface CustomMethodCardWizardCopy { footerFinalize: string; fieldModals: { addField: string; + saveField: string; requiredHint: string; text: CustomMethodCardWizardFieldBodiesCopy["text"] & { title: string; @@ -70,6 +72,8 @@ export interface CustomMethodCardWizardCopy { export interface CustomMethodCardWizardProps { isOpen: boolean; onClose: () => void; + /** When set, seeds title, description, and field blocks; wizard still starts at step 1. */ + initialValues?: MethodCardWizardInitialValues | null; /** Called when the user completes step 3; parent assigns id and persists state. */ onFinalize: (payload: { title: string; @@ -123,7 +127,8 @@ export interface CustomMethodCardWizardViewProps { policyDescription: string; addFieldExpanded: boolean; copy: CustomMethodCardWizardCopy; - maxChars: number; + maxTitleChars: number; + maxDescriptionChars: number; onPolicyTitleChange: (v: string) => void; onPolicyDescriptionChange: (v: string) => void; onPressAddCustomField: () => void; @@ -136,6 +141,7 @@ export interface CustomMethodCardWizardViewProps { >; draftFieldBlocks: CustomMethodCardFieldBlock[]; onDraftFieldBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void; + onEditFieldBlock: (_block: CustomMethodCardFieldBlock) => void; nextDisabled: boolean; nextLabel: string; showBackButton: boolean; diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx index aeefb01..29cbb79 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.view.tsx @@ -19,7 +19,8 @@ function CustomMethodCardWizardViewComponent({ policyDescription, addFieldExpanded, copy, - maxChars, + maxTitleChars, + maxDescriptionChars, onPolicyTitleChange, onPolicyDescriptionChange, onPressAddCustomField, @@ -35,6 +36,7 @@ function CustomMethodCardWizardViewComponent({ stepper, draftFieldBlocks, onDraftFieldBlocksReorder, + onEditFieldBlock, kebabMoreOptionsAriaLabel, kebabMenuAriaLabel, kebabMenuItems, @@ -71,7 +73,7 @@ function CustomMethodCardWizardViewComponent({ placeholder={copy.step1.fieldPlaceholder} value={policyTitle} onChange={onPolicyTitleChange} - maxLength={maxChars} + maxLength={maxTitleChars} /> ) : null} {!fieldTypeModal && wizardStep === 2 ? ( @@ -80,9 +82,9 @@ function CustomMethodCardWizardViewComponent({ formHeader={false} placeholder={copy.step2.fieldPlaceholder} value={policyDescription} - maxLength={maxChars} + maxLength={maxDescriptionChars} onChange={(e) => onPolicyDescriptionChange(e.target.value)} - textHint={`${policyDescription.length}/${maxChars}`} + textHint={`${policyDescription.length}/${maxDescriptionChars}`} className="w-full" rows={4} /> @@ -96,6 +98,7 @@ function CustomMethodCardWizardViewComponent({ dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel} listLabel={copy.step3BlocksList.listLabel} onBlocksReorder={onDraftFieldBlocksReorder} + onEditBlock={onEditFieldBlock} /> ) : null} (null); const [overIndex, setOverIndex] = useState(null); + const draggingIndexRef = useRef(null); + const dragFromHandleRef = useRef(false); const clearDragUi = useCallback(() => { + draggingIndexRef.current = null; + dragFromHandleRef.current = false; setDraggingIndex(null); setOverIndex(null); }, []); + const handleHandlePointerDown = useCallback(() => { + dragFromHandleRef.current = true; + }, []); + const handleDragStart = useCallback( (index: number) => (e: DragEvent) => { + if (!dragFromHandleRef.current) { + e.preventDefault(); + return; + } e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(index)); + draggingIndexRef.current = index; setDraggingIndex(index); }, [], @@ -40,8 +54,11 @@ function CustomMethodCardWizardBlocksListContainerComponent({ const handleDrop = useCallback( (index: number) => (e: DragEvent) => { e.preventDefault(); - const from = Number.parseInt(e.dataTransfer.getData("text/plain"), 10); - if (Number.isNaN(from)) { + const fromData = Number.parseInt(e.dataTransfer.getData("text/plain"), 10); + const from = Number.isNaN(fromData) + ? draggingIndexRef.current + : fromData; + if (from == null || Number.isNaN(from)) { clearDragUi(); return; } @@ -66,6 +83,8 @@ function CustomMethodCardWizardBlocksListContainerComponent({ onDragOver={handleDragOver} onDrop={handleDrop} onDragEnd={clearDragUi} + onHandlePointerDown={handleHandlePointerDown} + onEditBlock={onEditBlock} /> ); } diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.types.ts b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.types.ts index 8714348..87cf468 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.types.ts +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.types.ts @@ -8,6 +8,7 @@ export interface CustomMethodCardWizardBlocksListProps { dragHandleAriaLabel: string; listLabel: string; onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void; + onEditBlock: (_block: CustomMethodCardFieldBlock) => void; } export interface CustomMethodCardWizardBlocksListViewProps @@ -18,4 +19,5 @@ export interface CustomMethodCardWizardBlocksListViewProps onDragOver: (_index: number) => (_e: DragEvent) => void; onDrop: (_index: number) => (_e: DragEvent) => void; onDragEnd: () => void; + onHandlePointerDown: () => void; } diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.view.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.view.tsx index d1554c7..7c08fd2 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.view.tsx +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.view.tsx @@ -38,6 +38,8 @@ function CustomMethodCardWizardBlocksListViewComponent({ onDragOver, onDrop, onDragEnd, + onHandlePointerDown, + onEditBlock, }: CustomMethodCardWizardBlocksListViewProps) { return (
    - + + ); })} diff --git a/app/(app)/create/components/FinalReviewChipEditModal.tsx b/app/(app)/create/components/FinalReviewChipEditModal.tsx index b06c9f7..7ce9606 100644 --- a/app/(app)/create/components/FinalReviewChipEditModal.tsx +++ b/app/(app)/create/components/FinalReviewChipEditModal.tsx @@ -2,11 +2,11 @@ /** * Final-review chip modal: **Core values** and **method** facets share the - * kebab → **Customize** / **Duplicate** (values only when under the cap) / - * **Remove** pattern from the create-card facet modals (`Create` + - * {@link buildCustomRuleModalKebabMenu}). Core values use a single Customize - * header field for the value name; method chips use the full policy title + - * description pair. + * kebab → **Duplicate** (values only when under the cap) / **Remove** pattern + * from the create-card facet modals (`Create` + + * {@link buildCustomRuleModalKebabMenu}). Values and method chips also offer + * **Customize**, which opens {@link CustomMethodCardWizard} prefilled from the + * chip. Fields are editable on open; Save persists body edits without renaming. * * Template-only chips without an `overrideKey` never mount this component; they * use {@link TemplateChipDetailModal} from the parent. @@ -26,7 +26,6 @@ import { MembershipMethodEditFields, } from "./methodEditFields"; import CustomMethodCardModalBody from "./CustomMethodCardModalBody"; -import MethodCardCustomizeModalHeader from "./MethodCardCustomizeModalHeader"; import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu"; import { useDiscardCustomizeConfirm } from "../hooks/useDiscardCustomizeConfirm"; import { @@ -37,6 +36,7 @@ import { membershipPresetFor, } from "../../../../lib/create/finalReviewChipPresets"; import { isCustomMethodCardId } from "../../../../lib/create/isCustomMethodCardId"; +import { usesWizardFieldBlocksModalBody } from "../../../../lib/create/usesWizardFieldBlocksModalBody"; import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks"; import { CUSTOM_RULE_FACET_BY_GROUP, @@ -46,7 +46,17 @@ import type { MethodFacetGroupKey } from "../../../../lib/create/removeMethodCar import { removeMethodCardFromFacetSelection } from "../../../../lib/create/removeMethodCardFromFacetSelection"; import { mergePresetMethodsWithCustom } from "../../../../lib/create/mergePresetMethodsWithCustom"; import { moveFacetSelectionIdToFront } from "../../../../lib/create/methodCardSelectionOrder"; -import { usesWizardFieldBlocksModalBody } from "../../../../lib/create/usesWizardFieldBlocksModalBody"; +import { + buildMethodCardWizardInitialValues, + coreValueDetailsFromWizardFieldBlocks, + overlayFacetPrefillValues, +} from "../../../../lib/create/methodCardWizardPrefill"; +import type { + MethodCardWizardFacetPrefill, + MethodCardWizardInitialValues, +} from "../../../../lib/create/methodCardWizardPrefill"; +import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer"; +import CustomMethodCardWizard from "./CustomMethodCardWizard"; import { duplicateCoreValueChipInDraft, removeCoreValueChipFromDraft, @@ -82,6 +92,7 @@ export type FinalReviewChipEditPatch = value: CoreValueDetailEntry; /** When set, updates the display label for this chip id in `coreValuesChipsSnapshot`. */ chipLabel?: string; + customMethodCardFieldBlocks?: CustomMethodCardFieldBlock[]; } | { groupKey: "communication"; @@ -171,16 +182,20 @@ export function FinalReviewChipEditModal({ const tModal = useTranslation( "create.reviewAndComplete.finalReview.chipEditModal", ); - const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } = - useDiscardCustomizeConfirm(); + const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm(); const [draft, setDraft] = useState(null); - const [modalEditUnlocked, setModalEditUnlocked] = useState(false); const [draftFieldBlocks, setDraftFieldBlocks] = useState< CustomMethodCardFieldBlock[] | null >(null); const [customizeHeaderDraft, setCustomizeHeaderDraft] = useState(null); + const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); + const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState< + string | null + >(null); + const [wizardInitialValues, setWizardInitialValues] = + useState(null); const initialSnapshotRef = useRef(""); const seededTargetRef = useRef(null); @@ -265,19 +280,8 @@ export function FinalReviewChipEditModal({ ? 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; - if (modalEditUnlocked) { - return; - } const sig = facetSeedSignature(target, state); const targetKey = `${target.groupKey}:${target.overrideKey}:${sig}`; if (seededTargetRef.current === targetKey) { @@ -288,37 +292,69 @@ export function FinalReviewChipEditModal({ setDraft(seed); initialSnapshotRef.current = JSON.stringify(seed.value); if (target.groupKey === "coreValues") { - setModalEditUnlocked(false); - setCustomizeHeaderDraft(null); - coreCustomizeSnapshotRef.current = null; + const persisted = + state.customMethodCardFieldBlocksById?.[target.overrideKey]; + const initialBlocks = + Array.isArray(persisted) && persisted.length > 0 + ? structuredClone(persisted) + : null; + const headerDraft: MethodCardHeaderDraft = { + title: target.chipLabel, + description: "", + }; + setCustomizeHeaderDraft(headerDraft); + coreCustomizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + seed.value as CoreValueDetailEntry, + initialBlocks, + headerDraft, + ); + setDraftFieldBlocks(initialBlocks); } - if (isMethodFacetGroup(target.groupKey)) { - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); - customizeSnapshotRef.current = null; + if (isMethodFacetGroup(target.groupKey) && seed.groupKey !== "coreValues") { + const persisted = + state.customMethodCardFieldBlocksById?.[target.overrideKey]; + const initialBlocks = + Array.isArray(persisted) && persisted.length > 0 + ? structuredClone(persisted) + : isCustomMethodCardId( + target.overrideKey, + state.customMethodCardMetaById, + ) + ? structuredClone(persisted ?? []) + : null; + const method = methodById.get(target.overrideKey); + const meta = state.customMethodCardMetaById?.[target.overrideKey]; + const headerDraft: MethodCardHeaderDraft = { + title: meta?.label ?? method?.label ?? target.chipLabel, + description: meta?.supportText ?? method?.supportText ?? "", + }; + setCustomizeHeaderDraft(headerDraft); + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + seed.value as MethodDetailDraft, + initialBlocks, + headerDraft, + ); + setDraftFieldBlocks(initialBlocks); } seededTargetRef.current = targetKey; - }, [isOpen, target, state, modalEditUnlocked]); + }, [isOpen, target, state, methodById]); useEffect(() => { if (!isOpen) seededTargetRef.current = null; }, [isOpen]); 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, + draftFieldBlocks, customizeHeaderDraft, ); - }, [customizeHeaderDraft, draft, modalEditUnlocked]); + }, [customizeHeaderDraft, draft, draftFieldBlocks]); const methodCustomizeSaveDisabled = useMemo(() => { - if (!modalEditUnlocked) return false; const snap = customizeSnapshotRef.current; if (!snap) return true; return !isMethodCardCustomizeSessionDirty( @@ -331,16 +367,48 @@ export function FinalReviewChipEditModal({ customizeHeaderDraft, draft, draftFieldBlocks, - modalEditUnlocked, ]); + const modalUsesWizardFieldBlocksBody = Boolean( + target && + (usesWizardFieldBlocksModalBody({ + methodId: target.overrideKey, + meta: state.customMethodCardMetaById, + fieldBlocksById: state.customMethodCardFieldBlocksById, + modalEditUnlocked: true, + draftFieldBlocks, + }) || + isCustomMethodCardId( + target.overrideKey, + state.customMethodCardMetaById, + )), + ); + + const handleWizardFieldBlocksChange = useCallback( + (next: CustomMethodCardFieldBlock[]) => { + setDraftFieldBlocks(next); + setDraft((prev) => { + if (!prev || prev.groupKey !== "coreValues") { + return prev; + } + return { + groupKey: "coreValues", + value: coreValueDetailsFromWizardFieldBlocks(next, prev.value), + }; + }); + }, + [], + ); + const finalizeModalClose = useCallback(() => { customizeSnapshotRef.current = null; coreCustomizeSnapshotRef.current = null; pendingEphemeralCoreDuplicateRef.current = null; - setModalEditUnlocked(false); setDraftFieldBlocks(null); setCustomizeHeaderDraft(null); + setAddCustomWizardOpen(false); + setWizardCustomizeCardId(null); + setWizardInitialValues(null); onClose(); }, [onClose]); @@ -349,7 +417,7 @@ export function FinalReviewChipEditModal({ target && target.groupKey === "coreValues" && !(await confirmDiscard( - modalEditUnlocked, + true, coreCustomizeSnapshotRef.current, draft?.groupKey === "coreValues" ? draft.value : null, null, @@ -362,7 +430,7 @@ export function FinalReviewChipEditModal({ target && isMethodFacetGroup(target.groupKey) && !(await confirmDiscard( - modalEditUnlocked, + true, customizeSnapshotRef.current, methodDetailDraftForCustomizeSession(draft), draftFieldBlocks, @@ -385,182 +453,102 @@ export function FinalReviewChipEditModal({ draft, draftFieldBlocks, finalizeModalClose, - modalEditUnlocked, replaceState, target, ]); - const handleCancelCustomize = useCallback(async () => { - if (!modalEditUnlocked || !target) { + const handleCustomize = useCallback(() => { + onInteract?.(); + if (!target) { return; } if (target.groupKey === "coreValues") { - const snap = coreCustomizeSnapshotRef.current; - if (!snap) { - coreCustomizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setCustomizeHeaderDraft(null); - return; - } - if ( - draft?.groupKey === "coreValues" && - !(await confirmDirtyCustomizeCancel( - snap, - draft.value, - null, - customizeHeaderDraft, - )) - ) { - return; - } - setDraft({ - groupKey: "coreValues", - value: structuredClone(snap.pendingDraft), - }); - coreCustomizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setCustomizeHeaderDraft(null); + const value = + draft?.groupKey === "coreValues" + ? draft.value + : coreValuePresetFor(target.overrideKey); + setWizardInitialValues( + buildMethodCardWizardInitialValues({ + cardId: target.overrideKey, + fallbackTitle: target.chipLabel, + fallbackDescription: + value.supportText?.trim() || tCv.detailModal.subtitle, + meta: {}, + persistedBlocks: state.customMethodCardFieldBlocksById, + draftFieldBlocks: null, + facetPrefill: { + group: "coreValues", + draft: value, + headings: { + meaning: tCv.detailModal.meaningLabel, + signals: tCv.detailModal.signalsLabel, + }, + }, + }), + ); + setWizardCustomizeCardId(target.overrideKey); + setAddCustomWizardOpen(true); return; } if (!isMethodFacetGroup(target.groupKey)) { return; } - const snap = customizeSnapshotRef.current; - if (!snap) { - customizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); - return; + const method = methodById.get(target.overrideKey); + const meta = state.customMethodCardMetaById?.[target.overrideKey]; + let facetPrefill: MethodCardWizardFacetPrefill | undefined; + if (draft && draft.groupKey === "communication") { + facetPrefill = { + group: "communication", + draft: draft.value, + headings: tComm.sectionHeadings, + }; + } else if (draft && draft.groupKey === "membership") { + facetPrefill = { + group: "membership", + draft: draft.value, + headings: tMem.sectionHeadings, + }; + } else if (draft && draft.groupKey === "decisionApproaches") { + facetPrefill = { + group: "decisionApproaches", + draft: draft.value, + headings: tDa.sectionHeadings, + }; + } else if (draft && draft.groupKey === "conflictManagement") { + facetPrefill = { + group: "conflictManagement", + draft: draft.value, + headings: tCm.sectionHeadings, + }; } - if ( - !(await confirmDirtyCustomizeCancel( - snap, - methodDetailDraftForCustomizeSession(draft), + setWizardInitialValues( + buildMethodCardWizardInitialValues({ + cardId: target.overrideKey, + fallbackTitle: method?.label ?? target.chipLabel, + fallbackDescription: method?.supportText ?? meta?.supportText ?? "", + meta: state.customMethodCardMetaById, + persistedBlocks: state.customMethodCardFieldBlocksById, draftFieldBlocks, - customizeHeaderDraft, - )) - ) { - return; - } - setPendingDraftFromSnapshot(snap); - setDraftFieldBlocks(null); - setModalEditUnlocked(false); - customizeSnapshotRef.current = null; - setCustomizeHeaderDraft(null); + facetPrefill, + }), + ); + setWizardCustomizeCardId(target.overrideKey); + setAddCustomWizardOpen(true); }, [ - confirmDirtyCustomizeCancel, - customizeHeaderDraft, draft, draftFieldBlocks, - modalEditUnlocked, - 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, + tCm.sectionHeadings, + tComm.sectionHeadings, + tCv.detailModal.meaningLabel, + tCv.detailModal.signalsLabel, + tCv.detailModal.subtitle, + tDa.sectionHeadings, + tMem.sectionHeadings, target, - tComm, - tMem, - tDa, - tCm, ]); const handleRemoveSelectedFromModal = useCallback(async () => { @@ -574,7 +562,7 @@ export function FinalReviewChipEditModal({ onInteract?.(); if ( !(await confirmDiscard( - modalEditUnlocked, + true, customizeSnapshotRef.current, methodDetailDraftForCustomizeSession(draft), draftFieldBlocks, @@ -599,7 +587,6 @@ export function FinalReviewChipEditModal({ draft, draftFieldBlocks, finalizeModalClose, - modalEditUnlocked, onInteract, replaceState, selectionIdsForTarget, @@ -613,7 +600,7 @@ export function FinalReviewChipEditModal({ onInteract?.(); if ( !(await confirmDiscard( - modalEditUnlocked, + true, coreCustomizeSnapshotRef.current, draft?.groupKey === "coreValues" ? draft.value : null, null, @@ -634,7 +621,6 @@ export function FinalReviewChipEditModal({ customizeHeaderDraft, draft, finalizeModalClose, - modalEditUnlocked, onInteract, replaceState, target, @@ -656,7 +642,7 @@ export function FinalReviewChipEditModal({ } if ( !(await confirmDiscard( - modalEditUnlocked, + true, coreCustomizeSnapshotRef.current, draft.value, null, @@ -691,7 +677,6 @@ export function FinalReviewChipEditModal({ } customizeSnapshotRef.current = null; coreCustomizeSnapshotRef.current = null; - setModalEditUnlocked(false); setDraftFieldBlocks(null); setCustomizeHeaderDraft(null); pendingEphemeralCoreDuplicateRef.current = outcome.newId; @@ -709,7 +694,6 @@ export function FinalReviewChipEditModal({ confirmDiscard, customizeHeaderDraft, draft, - modalEditUnlocked, modalKebabMenu.duplicateTitleSuffix, onEditTargetChange, onInteract, @@ -723,7 +707,7 @@ export function FinalReviewChipEditModal({ if (!target) return []; if (target.groupKey === "coreValues") { return buildCustomRuleModalKebabMenu(modalKebabMenu, { - showCustomize: !modalEditUnlocked, + showCustomize: true, onCustomize: handleCustomize, onDuplicate: (state.editingPublishedRuleId?.trim() ?? "") !== "" || @@ -736,7 +720,7 @@ export function FinalReviewChipEditModal({ } if (!isMethodFacetGroup(target.groupKey)) return []; return buildCustomRuleModalKebabMenu(modalKebabMenu, { - showCustomize: !modalEditUnlocked, + showCustomize: true, onCustomize: handleCustomize, showRemove: isChipInSelection, onRemove: handleRemoveSelectedFromModal, @@ -747,7 +731,6 @@ export function FinalReviewChipEditModal({ handleRemoveCoreValueFromModal, handleRemoveSelectedFromModal, isChipInSelection, - modalEditUnlocked, modalKebabMenu, state.editingPublishedRuleId, state.selectedCoreValueIds, @@ -760,86 +743,81 @@ export function FinalReviewChipEditModal({ target, { tCv, tComm, tMem, tDa, tCm }, state.customMethodCardMetaById, + state.coreValueDetailsByChipId?.[target.overrideKey]?.supportText, ); - }, [target, tCv, tComm, tMem, tDa, tCm, state.customMethodCardMetaById]); + }, [ + target, + tCv, + tComm, + tMem, + tDa, + tCm, + state.customMethodCardMetaById, + state.coreValueDetailsByChipId, + ]); const handleCoreSave = useCallback(() => { if (!target || !draft || draft.groupKey !== "coreValues") { return; } - if (!modalEditUnlocked || !customizeHeaderDraft) { - return; - } if (coreCustomizeSaveDisabled) { return; } - const labelTrim = customizeHeaderDraft.title.trim(); onInteract?.(); + const existingBlocks = + state.customMethodCardFieldBlocksById?.[target.overrideKey]; onSave({ groupKey: "coreValues", overrideKey: target.overrideKey, value: structuredClone(draft.value), - ...(labelTrim.length > 0 ? { chipLabel: labelTrim } : {}), + ...(existingBlocks && existingBlocks.length > 0 + ? { + customMethodCardFieldBlocks: overlayFacetPrefillValues( + existingBlocks, + { + group: "coreValues", + draft: draft.value, + headings: { + meaning: tCv.detailModal.meaningLabel, + signals: tCv.detailModal.signalsLabel, + }, + }, + ), + } + : {}), }); - coreCustomizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setCustomizeHeaderDraft(null); + coreCustomizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + draft.value, + null, + customizeHeaderDraft ?? { + title: target.chipLabel, + description: "", + }, + ); 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, + state.customMethodCardFieldBlocksById, + tCv.detailModal.meaningLabel, + tCv.detailModal.signalsLabel, target, ]); const handleMethodPrimary = useCallback(() => { if (!target || !draft || !isMethodFacetGroup(target.groupKey)) return; + if (!isMethodFacetGroup(draft.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 + const persistWizardBlocks = draftFieldBlocks !== null; + const blocksPayload = persistWizardBlocks ? structuredClone(draftFieldBlocks ?? []) : undefined; @@ -849,7 +827,6 @@ export function FinalReviewChipEditModal({ groupKey: "communication", overrideKey: pendingId, value: draft.value, - methodCardMeta: metaSave, ...(blocksPayload !== undefined ? { customMethodCardFieldBlocks: blocksPayload } : {}), @@ -860,7 +837,6 @@ export function FinalReviewChipEditModal({ groupKey: "membership", overrideKey: pendingId, value: draft.value, - methodCardMeta: metaSave, ...(blocksPayload !== undefined ? { customMethodCardFieldBlocks: blocksPayload } : {}), @@ -871,7 +847,6 @@ export function FinalReviewChipEditModal({ groupKey: "decisionApproaches", overrideKey: pendingId, value: draft.value, - methodCardMeta: metaSave, ...(blocksPayload !== undefined ? { customMethodCardFieldBlocks: blocksPayload } : {}), @@ -882,23 +857,38 @@ export function FinalReviewChipEditModal({ groupKey: "conflictManagement", overrideKey: pendingId, value: draft.value, - methodCardMeta: metaSave, ...(blocksPayload !== undefined ? { customMethodCardFieldBlocks: blocksPayload } : {}), }); break; } - customizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + + if (!sel.includes(pendingId)) { + replaceState((prev) => ({ + ...prev, + [facet.selectedIdsStateKey]: moveFacetSelectionIdToFront( + [...facet.selectionIds(prev)], + pendingId, + ), + })); + finalizeModalClose(); + return; + } + + const sessionDraft = methodDetailDraftForCustomizeSession(draft); + if (sessionDraft) { + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + sessionDraft, + draftFieldBlocks, + customizeHeaderDraft ?? { title: "", description: "" }, + ); + } }, [ customizeHeaderDraft, draft, draftFieldBlocks, - modalEditUnlocked, - onClose, + finalizeModalClose, onInteract, onSave, replaceState, @@ -906,6 +896,137 @@ export function FinalReviewChipEditModal({ target, ]); + const handleCloseAddWizard = useCallback(() => { + setAddCustomWizardOpen(false); + setWizardCustomizeCardId(null); + setWizardInitialValues(null); + }, []); + + const handleFinalizeCustomCard = useCallback( + ({ + title, + description, + fieldBlocks, + }: { + title: string; + description: string; + fieldBlocks: CustomMethodCardFieldBlock[]; + }) => { + if (!target || !draft) { + return; + } + if (target.groupKey === "coreValues" && draft.groupKey === "coreValues") { + const existingId = wizardCustomizeCardId ?? target.overrideKey; + onInteract?.(); + const details = coreValueDetailsFromWizardFieldBlocks( + fieldBlocks, + draft.value, + ); + const trimmedDescription = description.trim(); + onSave({ + groupKey: "coreValues", + overrideKey: existingId, + value: { + ...details, + ...(trimmedDescription.length > 0 + ? { supportText: trimmedDescription } + : {}), + }, + chipLabel: title, + customMethodCardFieldBlocks: structuredClone(fieldBlocks), + }); + setDraft({ + groupKey: "coreValues", + value: { + ...details, + ...(trimmedDescription.length > 0 + ? { supportText: trimmedDescription } + : {}), + }, + }); + setDraftFieldBlocks(structuredClone(fieldBlocks)); + coreCustomizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + { + ...details, + ...(trimmedDescription.length > 0 + ? { supportText: trimmedDescription } + : {}), + }, + fieldBlocks, + { title, description }, + ); + setAddCustomWizardOpen(false); + setWizardCustomizeCardId(null); + setWizardInitialValues(null); + return; + } + if ( + !isMethodFacetGroup(target.groupKey) || + !isMethodFacetGroup(draft.groupKey) + ) { + return; + } + const existingId = wizardCustomizeCardId ?? target.overrideKey; + onInteract?.(); + const meta = { label: title, supportText: description }; + const blocks = structuredClone(fieldBlocks); + switch (draft.groupKey) { + case "communication": + onSave({ + groupKey: "communication", + overrideKey: existingId, + value: draft.value, + methodCardMeta: meta, + customMethodCardFieldBlocks: blocks, + }); + break; + case "membership": + onSave({ + groupKey: "membership", + overrideKey: existingId, + value: draft.value, + methodCardMeta: meta, + customMethodCardFieldBlocks: blocks, + }); + break; + case "decisionApproaches": + onSave({ + groupKey: "decisionApproaches", + overrideKey: existingId, + value: draft.value, + methodCardMeta: meta, + customMethodCardFieldBlocks: blocks, + }); + break; + case "conflictManagement": + onSave({ + groupKey: "conflictManagement", + overrideKey: existingId, + value: draft.value, + methodCardMeta: meta, + customMethodCardFieldBlocks: blocks, + }); + break; + } + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + draft.value, + blocks, + { title, description }, + ); + setDraftFieldBlocks(blocks); + setAddCustomWizardOpen(false); + setWizardCustomizeCardId(null); + setWizardInitialValues(null); + }, + [ + draft, + onInteract, + onSave, + target, + wizardCustomizeCardId, + ], + ); + const handleNext = useCallback(() => { if (!target || !draft) return; if (target.groupKey === "coreValues") { @@ -917,72 +1038,16 @@ export function FinalReviewChipEditModal({ 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); + if (!isChipInSelection) { + return addPrimaryLabelForMethodFacet(target.groupKey, cr); + } return tModal("saveButton"); - }, [ - cr, - isChipInSelection, - modalEditUnlocked, - modalKebabMenu.saveEdits, - target, - tModal, - ]); + }, [cr, isChipInSelection, 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 (
    @@ -994,42 +1059,22 @@ export function FinalReviewChipEditModal({ />
    ); - }, [ - customizeHeaderDraft, - modalEditUnlocked, - modalKebabMenu.customizePolicyDescriptionLabel, - modalKebabMenu.customizePolicyTitleLabel, - subtitle, - target, - tCv.detailModal.customizeValueNameLabel, - ]); - - const showNext = - target?.groupKey === "coreValues" - ? showCoreModalPrimary - : showMethodModalPrimary; + }, [subtitle, target]); return ( <>
    - {draft?.groupKey === "coreValues" && ( - setDraft({ groupKey: "coreValues", value })} - /> - )} - {draft?.groupKey === "communication" && + {draft?.groupKey === "coreValues" && target && - (isCustomMethodCardId( - target.overrideKey, - state.customMethodCardMetaById, - ) ? ( + (modalUsesWizardFieldBlocksBody ? ( + ) : ( + setDraft({ groupKey: "coreValues", value })} + /> + ))} + {draft?.groupKey === "communication" && + target && + (modalUsesWizardFieldBlocksBody ? ( + setDraftFieldBlocks(next) + : handleWizardFieldBlocksChange } /> ) : ( @@ -1076,31 +1131,25 @@ export function FinalReviewChipEditModal({ onChange={(value) => setDraft({ groupKey: "communication", value }) } - readOnly={fieldsLocked} /> ))} {draft?.groupKey === "membership" && target && - (isCustomMethodCardId( - target.overrideKey, - state.customMethodCardMetaById, - ) ? ( + (modalUsesWizardFieldBlocksBody ? ( setDraftFieldBlocks(next) + : handleWizardFieldBlocksChange } /> ) : ( @@ -1109,31 +1158,25 @@ export function FinalReviewChipEditModal({ onChange={(value) => setDraft({ groupKey: "membership", value }) } - readOnly={fieldsLocked} /> ))} {draft?.groupKey === "decisionApproaches" && target && - (isCustomMethodCardId( - target.overrideKey, - state.customMethodCardMetaById, - ) ? ( + (modalUsesWizardFieldBlocksBody ? ( setDraftFieldBlocks(next) + : handleWizardFieldBlocksChange } /> ) : ( @@ -1142,31 +1185,25 @@ export function FinalReviewChipEditModal({ onChange={(value) => setDraft({ groupKey: "decisionApproaches", value }) } - readOnly={fieldsLocked} /> ))} {draft?.groupKey === "conflictManagement" && target && - (isCustomMethodCardId( - target.overrideKey, - state.customMethodCardMetaById, - ) ? ( + (modalUsesWizardFieldBlocksBody ? ( setDraftFieldBlocks(next) + : handleWizardFieldBlocksChange } /> ) : ( @@ -1175,11 +1212,19 @@ export function FinalReviewChipEditModal({ onChange={(value) => setDraft({ groupKey: "conflictManagement", value }) } - readOnly={fieldsLocked} /> ))}
    + + uploadCreateFlowFile(file, "customMethodAttachment") + } + /> {confirmDialog} ); @@ -1198,6 +1243,7 @@ function facetSeedSignature( details: state.coreValueDetailsByChipId?.[id], row: state.coreValuesChipsSnapshot?.find((r) => r.id === id) ?? null, + blocks: state.customMethodCardFieldBlocksById?.[id] ?? null, }); case "communication": return JSON.stringify({ @@ -1230,46 +1276,6 @@ function facetSeedSignature( } } -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"], @@ -1299,6 +1305,7 @@ function seedDraftForTarget( value: { meaning: saved?.meaning ?? preset.meaning, signals: saved?.signals ?? preset.signals, + ...(saved?.supportText ? { supportText: saved.supportText } : {}), }, }; } @@ -1359,10 +1366,14 @@ function subtitleForTarget( target: FinalReviewChipEditTarget, msgs: SubtitleMessages, customMeta?: CreateFlowState["customMethodCardMetaById"], + coreValueSupportText?: string, ): string { switch (target.groupKey) { - case "coreValues": + case "coreValues": { + const fromSaved = coreValueSupportText?.trim(); + if (fromSaved) return fromSaved; return msgs.tCv.detailModal.subtitle; + } case "communication": { const fromCustom = customMeta?.[target.overrideKey]?.supportText?.trim(); if (fromCustom) return fromCustom; diff --git a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx index 6bf15d9..63f7419 100644 --- a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx +++ b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx @@ -53,20 +53,20 @@ import type { CommunicationMethodDetailEntry } from "../../types"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; +import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; +import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import { captureMethodCardCustomizeSnapshot, 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 { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } = - useDiscardCustomizeConfirm(); + const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm(); const { state, updateState, replaceState, markCreateFlowInteraction } = useCreateFlow(); const pendingEphemeralDuplicateIdRef = useRef(null); @@ -79,12 +79,14 @@ export function CommunicationMethodsScreen() { const [pendingDraft, setPendingDraft] = useState(null); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); - const [modalEditUnlocked, setModalEditUnlocked] = useState(false); + const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState< + string | null + >(null); + const [wizardInitialValues, setWizardInitialValues] = + useState(null); const [draftFieldBlocks, setDraftFieldBlocks] = useState< CustomMethodCardFieldBlock[] | null >(null); - const [customizeHeaderDraft, setCustomizeHeaderDraft] = - useState(null); const selectedIds = state.selectedCommunicationMethodIds ?? []; @@ -107,6 +109,8 @@ export function CommunicationMethodsScreen() { const handleOpenAddWizard = useCallback(() => { markCreateFlowInteraction(); + setWizardCustomizeCardId(null); + setWizardInitialValues(null); setAddCustomWizardOpen(true); }, [markCreateFlowInteraction]); @@ -144,15 +148,40 @@ export function CommunicationMethodsScreen() { const handleCardClick = useCallback( (id: string) => { markCreateFlowInteraction(); - customizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + const draft = seedDraft(id); + const persistedBlocks = state.customMethodCardFieldBlocksById?.[id]; + const initialBlocks = + Array.isArray(persistedBlocks) && persistedBlocks.length > 0 + ? structuredClone(persistedBlocks) + : null; + const method = methodById.get(id); + const meta = state.customMethodCardMetaById?.[id]; + const headerDraft: MethodCardHeaderDraft = { + title: meta?.label ?? method?.label ?? comm.confirmModal.title, + description: + meta?.supportText ?? + method?.supportText ?? + comm.confirmModal.description, + }; + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + draft, + initialBlocks, + headerDraft, + ); setPendingCardId(id); - setPendingDraft(seedDraft(id)); + setPendingDraft(draft); + setDraftFieldBlocks(initialBlocks); setCreateModalOpen(true); }, - [markCreateFlowInteraction, seedDraft], + [ + comm.confirmModal.description, + comm.confirmModal.title, + markCreateFlowInteraction, + methodById, + seedDraft, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + ], ); const handleDraftChange = useCallback( @@ -165,9 +194,7 @@ export function CommunicationMethodsScreen() { const isSelectedCardModal = pendingCardId !== null && selectedIds.includes(pendingCardId); - const fieldsLocked = !modalEditUnlocked; - - const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked; + const showMethodModalPrimary = true; const customFacetDetailsMatchPreset = useMemo(() => { if (!pendingCardId || !pendingDraft) return false; @@ -189,7 +216,7 @@ export function CommunicationMethodsScreen() { methodId: pendingCardId, meta: state.customMethodCardMetaById, fieldBlocksById: state.customMethodCardFieldBlocksById, - modalEditUnlocked, + modalEditUnlocked: false, draftFieldBlocks, customFacetDetailsMatchPreset, }), @@ -197,7 +224,6 @@ export function CommunicationMethodsScreen() { [ customFacetDetailsMatchPreset, draftFieldBlocks, - modalEditUnlocked, pendingCardId, state.customMethodCardFieldBlocksById, state.customMethodCardMetaById, @@ -207,11 +233,11 @@ export function CommunicationMethodsScreen() { const handleCreateModalClose = useCallback(async () => { if ( !(await confirmDiscard( - modalEditUnlocked, + true, customizeSnapshotRef.current, pendingDraft, draftFieldBlocks, - customizeHeaderDraft, + customizeSnapshotRef.current?.headerDraft ?? null, )) ) { return; @@ -239,53 +265,14 @@ export function CommunicationMethodsScreen() { setCreateModalOpen(false); setPendingCardId(null); setPendingDraft(null); - setModalEditUnlocked(false); setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); }, [ confirmDiscard, - customizeHeaderDraft, draftFieldBlocks, - modalEditUnlocked, pendingDraft, replaceState, ]); - const handleCancelCustomize = useCallback(async () => { - if (!modalEditUnlocked) { - return; - } - const snap = customizeSnapshotRef.current; - if (!snap) { - customizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); - return; - } - if ( - !(await confirmDirtyCustomizeCancel( - snap, - pendingDraft, - draftFieldBlocks, - customizeHeaderDraft, - )) - ) { - return; - } - setPendingDraft(structuredClone(snap.pendingDraft)); - setDraftFieldBlocks(null); - setModalEditUnlocked(false); - customizeSnapshotRef.current = null; - setCustomizeHeaderDraft(null); - }, [ - confirmDirtyCustomizeCancel, - customizeHeaderDraft, - draftFieldBlocks, - modalEditUnlocked, - pendingDraft, - ]); - const handleRemoveSelectedFromModal = useCallback(async () => { if (!pendingCardId || !selectedIds.includes(pendingCardId)) { return; @@ -293,11 +280,11 @@ export function CommunicationMethodsScreen() { markCreateFlowInteraction(); if ( !(await confirmDiscard( - modalEditUnlocked, + true, customizeSnapshotRef.current, pendingDraft, draftFieldBlocks, - customizeHeaderDraft, + customizeSnapshotRef.current?.headerDraft ?? null, )) ) { return; @@ -313,11 +300,9 @@ export function CommunicationMethodsScreen() { await handleCreateModalClose(); }, [ confirmDiscard, - customizeHeaderDraft, draftFieldBlocks, handleCreateModalClose, markCreateFlowInteraction, - modalEditUnlocked, pendingDraft, pendingCardId, selectedIds, @@ -327,37 +312,36 @@ export function CommunicationMethodsScreen() { const handleCustomize = useCallback(() => { markCreateFlowInteraction(); - if (!pendingDraft || !pendingCardId) { + if (!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, + setWizardInitialValues( + buildMethodCardWizardInitialValues({ + cardId: pendingCardId, + fallbackTitle: method?.label ?? comm.confirmModal.title, + fallbackDescription: + method?.supportText ?? comm.confirmModal.description, + meta: state.customMethodCardMetaById, + persistedBlocks: state.customMethodCardFieldBlocksById, + draftFieldBlocks, + facetPrefill: pendingDraft + ? { + group: "communication", + draft: pendingDraft, + headings: comm.sectionHeadings, + } + : undefined, + }), ); - setDraftFieldBlocks(initialFieldBlocks); - setModalEditUnlocked(true); + setWizardCustomizeCardId(pendingCardId); + setCreateModalOpen(false); + setAddCustomWizardOpen(true); }, [ comm.confirmModal.description, comm.confirmModal.title, + comm.sectionHeadings, + draftFieldBlocks, markCreateFlowInteraction, methodById, pendingCardId, @@ -382,9 +366,7 @@ export function CommunicationMethodsScreen() { () => communicationPresetFor(newId), ); const blocksClone = structuredClone( - modalEditUnlocked && - draftFieldBlocks !== null && - isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + draftFieldBlocks !== null ? draftFieldBlocks : cloneMethodCardBlocksForDuplicate( state.customMethodCardFieldBlocksById, @@ -414,16 +396,15 @@ export function CommunicationMethodsScreen() { customizeSnapshotRef.current = null; setPendingCardId(newId); setPendingDraft(structuredClone(detailsClone)); - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + setDraftFieldBlocks( + blocksClone.length > 0 ? structuredClone(blocksClone) : null, + ); }, [ markCreateFlowInteraction, modalKebabMenu.duplicateTitleSuffix, pendingCardId, pendingDraft, draftFieldBlocks, - modalEditUnlocked, state.communicationMethodDetailsById, state.customMethodCardFieldBlocksById, state.customMethodCardMetaById, @@ -449,9 +430,7 @@ export function CommunicationMethodsScreen() { () => communicationPresetFor(newId), ); const blocksClone = structuredClone( - modalEditUnlocked && - draftFieldBlocks !== null && - isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + draftFieldBlocks !== null ? draftFieldBlocks : cloneMethodCardBlocksForDuplicate( state.customMethodCardFieldBlocksById, @@ -481,14 +460,13 @@ export function CommunicationMethodsScreen() { customizeSnapshotRef.current = null; setPendingCardId(newId); setPendingDraft(structuredClone(detailsClone)); - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + setDraftFieldBlocks( + blocksClone.length > 0 ? structuredClone(blocksClone) : null, + ); }, [ draftFieldBlocks, markCreateFlowInteraction, methodById, - modalEditUnlocked, modalKebabMenu.duplicateTitleSuffix, pendingCardId, pendingDraft, @@ -501,7 +479,7 @@ export function CommunicationMethodsScreen() { const kebabMenuItems = useMemo( () => buildCustomRuleModalKebabMenu(modalKebabMenu, { - showCustomize: !modalEditUnlocked, + showCustomize: true, onCustomize: handleCustomize, onDuplicate: (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId @@ -521,7 +499,6 @@ export function CommunicationMethodsScreen() { handleDuplicatePrefabCard, handleRemoveSelectedFromModal, isSelectedCardModal, - modalEditUnlocked, modalKebabMenu, pendingCardId, state.customMethodCardMetaById, @@ -540,7 +517,7 @@ export function CommunicationMethodsScreen() { meta?.supportText ?? method?.supportText ?? comm.confirmModal.description, - nextButtonText: modalEditUnlocked + nextButtonText: isSelectedCardModal ? saveLabel : comm.addPlatform.nextButtonText, }; @@ -552,8 +529,14 @@ export function CommunicationMethodsScreen() { }; const handleCloseAddWizard = useCallback(() => { + const resumeCardId = wizardCustomizeCardId; setAddCustomWizardOpen(false); - }, []); + setWizardCustomizeCardId(null); + setWizardInitialValues(null); + if (resumeCardId && pendingCardId === resumeCardId) { + setCreateModalOpen(true); + } + }, [pendingCardId, wizardCustomizeCardId]); const handleFinalizeCustomCard = useCallback( ({ @@ -566,6 +549,42 @@ export function CommunicationMethodsScreen() { fieldBlocks: CustomMethodCardFieldBlock[]; }) => { markCreateFlowInteraction(); + const existingId = wizardCustomizeCardId; + if (existingId) { + updateState({ + selectedCommunicationMethodIds: moveFacetSelectionIdToFront( + selectedIds, + existingId, + ), + customMethodCardMetaById: methodCardMetaWithCustomizeHeader( + state.customMethodCardMetaById, + existingId, + { title, description }, + ), + ...(pendingDraft + ? { + communicationMethodDetailsById: { + ...(state.communicationMethodDetailsById ?? {}), + [existingId]: pendingDraft, + }, + } + : {}), + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [existingId]: fieldBlocks, + }, + }); + if (pendingDraft) { + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + pendingDraft, + fieldBlocks, + { title, description }, + ); + } + setDraftFieldBlocks(structuredClone(fieldBlocks)); + setAddCustomWizardOpen(false); + return; + } const id = crypto.randomUUID(); updateState({ selectedCommunicationMethodIds: moveFacetSelectionIdToFront( @@ -588,91 +607,29 @@ export function CommunicationMethodsScreen() { }, [ markCreateFlowInteraction, + pendingDraft, selectedIds, state.communicationMethodDetailsById, state.customMethodCardFieldBlocksById, state.customMethodCardMetaById, updateState, + wizardCustomizeCardId, ], ); const handleCreateModalPrimary = useCallback(() => { if (!pendingCardId) { - handleCreateModalClose(); + void handleCreateModalClose(); return; } markCreateFlowInteraction(); - if (selectedIds.includes(pendingCardId)) { - 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; - } - return; - } + const persistWizardBlocks = + modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null; - 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, - }) - ) { + if (selectedIds.includes(pendingCardId)) { + if (persistWizardBlocks) { updateState({ - customMethodCardMetaById: nextMeta, customMethodCardFieldBlocksById: { ...(state.customMethodCardFieldBlocksById ?? {}), [pendingCardId]: structuredClone(draftFieldBlocks ?? []), @@ -680,22 +637,27 @@ export function CommunicationMethodsScreen() { }); } else if (pendingDraft) { updateState({ - customMethodCardMetaById: nextMeta, communicationMethodDetailsById: { ...(state.communicationMethodDetailsById ?? {}), [pendingCardId]: pendingDraft, }, }); } - customizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + if (pendingDraft) { + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + pendingDraft, + persistWizardBlocks ? draftFieldBlocks : null, + customizeSnapshotRef.current?.headerDraft ?? { + title: "", + description: "", + }, + ); + } return; } if (!pendingDraft) { - handleCreateModalClose(); + void handleCreateModalClose(); return; } updateState({ @@ -707,15 +669,23 @@ export function CommunicationMethodsScreen() { ...(state.communicationMethodDetailsById ?? {}), [pendingCardId]: pendingDraft, }, + ...(persistWizardBlocks + ? { + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [pendingCardId]: structuredClone(draftFieldBlocks ?? []), + }, + } + : {}), }); pendingEphemeralDuplicateIdRef.current = null; - handleCreateModalClose(); + customizeSnapshotRef.current = null; + void handleCreateModalClose(); }, [ - customizeHeaderDraft, draftFieldBlocks, handleCreateModalClose, markCreateFlowInteraction, - modalEditUnlocked, + modalUsesWizardFieldBlocksBody, pendingCardId, pendingDraft, selectedIds, @@ -766,33 +736,10 @@ 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={modalEditUnlocked} - onBack={handleCancelCustomize} - backButtonText={modalKebabMenu.cancelCustomize} showNextButton={showMethodModalPrimary} backdropVariant="blurredYellow" kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} @@ -805,14 +752,14 @@ export function CommunicationMethodsScreen() { cardId={pendingCardId} blocksById={state.customMethodCardFieldBlocksById} blocksOverride={ - modalEditUnlocked && draftFieldBlocks !== null - ? draftFieldBlocks - : undefined + draftFieldBlocks !== null ? draftFieldBlocks : undefined } policyMeta={state.customMethodCardMetaById?.[pendingCardId]} - showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked} + showPolicyContentLockupWhenNoBlocks={ + draftFieldBlocks === null || draftFieldBlocks.length === 0 + } onFieldBlocksChange={ - fieldsLocked + draftFieldBlocks === null ? undefined : (next) => setDraftFieldBlocks(next) } @@ -821,7 +768,6 @@ export function CommunicationMethodsScreen() { ) ) : null} @@ -830,6 +776,7 @@ export function CommunicationMethodsScreen() { uploadCreateFlowFile(file, "customMethodAttachment") diff --git a/app/(app)/create/screens/card/ConflictManagementScreen.tsx b/app/(app)/create/screens/card/ConflictManagementScreen.tsx index 993e6ff..c469c91 100644 --- a/app/(app)/create/screens/card/ConflictManagementScreen.tsx +++ b/app/(app)/create/screens/card/ConflictManagementScreen.tsx @@ -50,20 +50,20 @@ import type { ConflictManagementDetailEntry } from "../../types"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; +import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; +import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import { captureMethodCardCustomizeSnapshot, 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 { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } = - useDiscardCustomizeConfirm(); + const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm(); const { state, updateState, replaceState, markCreateFlowInteraction } = useCreateFlow(); const pendingEphemeralDuplicateIdRef = useRef(null); @@ -76,12 +76,14 @@ export function ConflictManagementScreen() { const [pendingDraft, setPendingDraft] = useState(null); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); - const [modalEditUnlocked, setModalEditUnlocked] = useState(false); + const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState< + string | null + >(null); + const [wizardInitialValues, setWizardInitialValues] = + useState(null); const [draftFieldBlocks, setDraftFieldBlocks] = useState< CustomMethodCardFieldBlock[] | null >(null); - const [customizeHeaderDraft, setCustomizeHeaderDraft] = - useState(null); const selectedIds = state.selectedConflictManagementIds ?? []; @@ -104,6 +106,8 @@ export function ConflictManagementScreen() { const handleOpenAddWizard = useCallback(() => { markCreateFlowInteraction(); + setWizardCustomizeCardId(null); + setWizardInitialValues(null); setAddCustomWizardOpen(true); }, [markCreateFlowInteraction]); @@ -145,15 +149,40 @@ export function ConflictManagementScreen() { const handleCardClick = useCallback( (id: string) => { markCreateFlowInteraction(); - customizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + const draft = seedDraft(id); + const persistedBlocks = state.customMethodCardFieldBlocksById?.[id]; + const initialBlocks = + Array.isArray(persistedBlocks) && persistedBlocks.length > 0 + ? structuredClone(persistedBlocks) + : null; + const method = methodById.get(id); + const meta = state.customMethodCardMetaById?.[id]; + const headerDraft: MethodCardHeaderDraft = { + title: meta?.label ?? method?.label ?? cm.confirmModal.title, + description: + meta?.supportText ?? + method?.supportText ?? + cm.confirmModal.description, + }; + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + draft, + initialBlocks, + headerDraft, + ); setPendingCardId(id); - setPendingDraft(seedDraft(id)); + setPendingDraft(draft); + setDraftFieldBlocks(initialBlocks); setCreateModalOpen(true); }, - [markCreateFlowInteraction, seedDraft], + [ + cm.confirmModal.description, + cm.confirmModal.title, + markCreateFlowInteraction, + methodById, + seedDraft, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + ], ); const handleDraftChange = useCallback( @@ -166,9 +195,7 @@ export function ConflictManagementScreen() { const isSelectedCardModal = pendingCardId !== null && selectedIds.includes(pendingCardId); - const fieldsLocked = !modalEditUnlocked; - - const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked; + const showMethodModalPrimary = true; const customFacetDetailsMatchPreset = useMemo(() => { if (!pendingCardId || !pendingDraft) return false; @@ -190,7 +217,7 @@ export function ConflictManagementScreen() { methodId: pendingCardId, meta: state.customMethodCardMetaById, fieldBlocksById: state.customMethodCardFieldBlocksById, - modalEditUnlocked, + modalEditUnlocked: false, draftFieldBlocks, customFacetDetailsMatchPreset, }), @@ -198,7 +225,6 @@ export function ConflictManagementScreen() { [ customFacetDetailsMatchPreset, draftFieldBlocks, - modalEditUnlocked, pendingCardId, state.customMethodCardFieldBlocksById, state.customMethodCardMetaById, @@ -208,11 +234,11 @@ export function ConflictManagementScreen() { const handleCreateModalClose = useCallback(async () => { if ( !(await confirmDiscard( - modalEditUnlocked, + true, customizeSnapshotRef.current, pendingDraft, draftFieldBlocks, - customizeHeaderDraft, + customizeSnapshotRef.current?.headerDraft ?? null, )) ) { return; @@ -240,53 +266,14 @@ export function ConflictManagementScreen() { setCreateModalOpen(false); setPendingCardId(null); setPendingDraft(null); - setModalEditUnlocked(false); setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); }, [ confirmDiscard, - customizeHeaderDraft, draftFieldBlocks, - modalEditUnlocked, pendingDraft, replaceState, ]); - const handleCancelCustomize = useCallback(async () => { - if (!modalEditUnlocked) { - return; - } - const snap = customizeSnapshotRef.current; - if (!snap) { - customizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); - return; - } - if ( - !(await confirmDirtyCustomizeCancel( - snap, - pendingDraft, - draftFieldBlocks, - customizeHeaderDraft, - )) - ) { - return; - } - setPendingDraft(structuredClone(snap.pendingDraft)); - setDraftFieldBlocks(null); - setModalEditUnlocked(false); - customizeSnapshotRef.current = null; - setCustomizeHeaderDraft(null); - }, [ - confirmDirtyCustomizeCancel, - customizeHeaderDraft, - draftFieldBlocks, - modalEditUnlocked, - pendingDraft, - ]); - const handleRemoveSelectedFromModal = useCallback(async () => { if (!pendingCardId || !selectedIds.includes(pendingCardId)) { return; @@ -294,11 +281,11 @@ export function ConflictManagementScreen() { markCreateFlowInteraction(); if ( !(await confirmDiscard( - modalEditUnlocked, + true, customizeSnapshotRef.current, pendingDraft, draftFieldBlocks, - customizeHeaderDraft, + customizeSnapshotRef.current?.headerDraft ?? null, )) ) { return; @@ -314,11 +301,9 @@ export function ConflictManagementScreen() { await handleCreateModalClose(); }, [ confirmDiscard, - customizeHeaderDraft, draftFieldBlocks, handleCreateModalClose, markCreateFlowInteraction, - modalEditUnlocked, pendingDraft, pendingCardId, selectedIds, @@ -328,35 +313,36 @@ export function ConflictManagementScreen() { const handleCustomize = useCallback(() => { markCreateFlowInteraction(); - if (!pendingDraft || !pendingCardId) { + if (!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, + setWizardInitialValues( + buildMethodCardWizardInitialValues({ + cardId: pendingCardId, + fallbackTitle: method?.label ?? cm.confirmModal.title, + fallbackDescription: + method?.supportText ?? cm.confirmModal.description, + meta: state.customMethodCardMetaById, + persistedBlocks: state.customMethodCardFieldBlocksById, + draftFieldBlocks, + facetPrefill: pendingDraft + ? { + group: "conflictManagement", + draft: pendingDraft, + headings: cm.sectionHeadings, + } + : undefined, + }), ); - setDraftFieldBlocks(initialFieldBlocks); - setModalEditUnlocked(true); + setWizardCustomizeCardId(pendingCardId); + setCreateModalOpen(false); + setAddCustomWizardOpen(true); }, [ cm.confirmModal.description, cm.confirmModal.title, + cm.sectionHeadings, + draftFieldBlocks, markCreateFlowInteraction, methodById, pendingCardId, @@ -381,9 +367,7 @@ export function ConflictManagementScreen() { () => conflictManagementPresetFor(newId), ); const blocksClone = structuredClone( - modalEditUnlocked && - draftFieldBlocks !== null && - isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + draftFieldBlocks !== null ? draftFieldBlocks : cloneMethodCardBlocksForDuplicate( state.customMethodCardFieldBlocksById, @@ -413,13 +397,12 @@ export function ConflictManagementScreen() { customizeSnapshotRef.current = null; setPendingCardId(newId); setPendingDraft(structuredClone(detailsClone)); - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + setDraftFieldBlocks( + blocksClone.length > 0 ? structuredClone(blocksClone) : null, + ); }, [ draftFieldBlocks, markCreateFlowInteraction, - modalEditUnlocked, modalKebabMenu.duplicateTitleSuffix, pendingCardId, pendingDraft, @@ -448,9 +431,7 @@ export function ConflictManagementScreen() { () => conflictManagementPresetFor(newId), ); const blocksClone = structuredClone( - modalEditUnlocked && - draftFieldBlocks !== null && - isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + draftFieldBlocks !== null ? draftFieldBlocks : cloneMethodCardBlocksForDuplicate( state.customMethodCardFieldBlocksById, @@ -480,14 +461,13 @@ export function ConflictManagementScreen() { customizeSnapshotRef.current = null; setPendingCardId(newId); setPendingDraft(structuredClone(detailsClone)); - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + setDraftFieldBlocks( + blocksClone.length > 0 ? structuredClone(blocksClone) : null, + ); }, [ draftFieldBlocks, markCreateFlowInteraction, methodById, - modalEditUnlocked, modalKebabMenu.duplicateTitleSuffix, pendingCardId, pendingDraft, @@ -500,7 +480,7 @@ export function ConflictManagementScreen() { const kebabMenuItems = useMemo( () => buildCustomRuleModalKebabMenu(modalKebabMenu, { - showCustomize: !modalEditUnlocked, + showCustomize: true, onCustomize: handleCustomize, onDuplicate: (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId @@ -520,7 +500,6 @@ export function ConflictManagementScreen() { handleDuplicatePrefabCard, handleRemoveSelectedFromModal, isSelectedCardModal, - modalEditUnlocked, modalKebabMenu, pendingCardId, state.customMethodCardMetaById, @@ -539,7 +518,7 @@ export function ConflictManagementScreen() { meta?.supportText ?? method?.supportText ?? cm.confirmModal.description, - nextButtonText: modalEditUnlocked + nextButtonText: isSelectedCardModal ? saveLabel : cm.addApproach.nextButtonText, }; @@ -551,8 +530,14 @@ export function ConflictManagementScreen() { }; const handleCloseAddWizard = useCallback(() => { + const resumeCardId = wizardCustomizeCardId; setAddCustomWizardOpen(false); - }, []); + setWizardCustomizeCardId(null); + setWizardInitialValues(null); + if (resumeCardId && pendingCardId === resumeCardId) { + setCreateModalOpen(true); + } + }, [pendingCardId, wizardCustomizeCardId]); const handleFinalizeCustomCard = useCallback( ({ @@ -565,6 +550,42 @@ export function ConflictManagementScreen() { fieldBlocks: CustomMethodCardFieldBlock[]; }) => { markCreateFlowInteraction(); + const existingId = wizardCustomizeCardId; + if (existingId) { + updateState({ + selectedConflictManagementIds: moveFacetSelectionIdToFront( + selectedIds, + existingId, + ), + customMethodCardMetaById: methodCardMetaWithCustomizeHeader( + state.customMethodCardMetaById, + existingId, + { title, description }, + ), + ...(pendingDraft + ? { + conflictManagementDetailsById: { + ...(state.conflictManagementDetailsById ?? {}), + [existingId]: pendingDraft, + }, + } + : {}), + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [existingId]: fieldBlocks, + }, + }); + if (pendingDraft) { + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + pendingDraft, + fieldBlocks, + { title, description }, + ); + } + setDraftFieldBlocks(structuredClone(fieldBlocks)); + setAddCustomWizardOpen(false); + return; + } const id = crypto.randomUUID(); updateState({ selectedConflictManagementIds: moveFacetSelectionIdToFront( @@ -587,91 +608,29 @@ export function ConflictManagementScreen() { }, [ markCreateFlowInteraction, + pendingDraft, selectedIds, state.conflictManagementDetailsById, state.customMethodCardFieldBlocksById, state.customMethodCardMetaById, updateState, + wizardCustomizeCardId, ], ); const handleCreateModalPrimary = useCallback(() => { if (!pendingCardId) { - handleCreateModalClose(); + void handleCreateModalClose(); return; } markCreateFlowInteraction(); - if (selectedIds.includes(pendingCardId)) { - 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; - } - return; - } + const persistWizardBlocks = + modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null; - 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, - }) - ) { + if (selectedIds.includes(pendingCardId)) { + if (persistWizardBlocks) { updateState({ - customMethodCardMetaById: nextMeta, customMethodCardFieldBlocksById: { ...(state.customMethodCardFieldBlocksById ?? {}), [pendingCardId]: structuredClone(draftFieldBlocks ?? []), @@ -679,22 +638,27 @@ export function ConflictManagementScreen() { }); } else if (pendingDraft) { updateState({ - customMethodCardMetaById: nextMeta, conflictManagementDetailsById: { ...(state.conflictManagementDetailsById ?? {}), [pendingCardId]: pendingDraft, }, }); } - customizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + if (pendingDraft) { + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + pendingDraft, + persistWizardBlocks ? draftFieldBlocks : null, + customizeSnapshotRef.current?.headerDraft ?? { + title: "", + description: "", + }, + ); + } return; } if (!pendingDraft) { - handleCreateModalClose(); + void handleCreateModalClose(); return; } updateState({ @@ -706,15 +670,23 @@ export function ConflictManagementScreen() { ...(state.conflictManagementDetailsById ?? {}), [pendingCardId]: pendingDraft, }, + ...(persistWizardBlocks + ? { + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [pendingCardId]: structuredClone(draftFieldBlocks ?? []), + }, + } + : {}), }); pendingEphemeralDuplicateIdRef.current = null; - handleCreateModalClose(); + customizeSnapshotRef.current = null; + void handleCreateModalClose(); }, [ - customizeHeaderDraft, draftFieldBlocks, handleCreateModalClose, markCreateFlowInteraction, - modalEditUnlocked, + modalUsesWizardFieldBlocksBody, pendingCardId, pendingDraft, selectedIds, @@ -765,33 +737,10 @@ 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={modalEditUnlocked} - onBack={handleCancelCustomize} - backButtonText={modalKebabMenu.cancelCustomize} showNextButton={showMethodModalPrimary} backdropVariant="blurredYellow" kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} @@ -804,14 +753,14 @@ export function ConflictManagementScreen() { cardId={pendingCardId} blocksById={state.customMethodCardFieldBlocksById} blocksOverride={ - modalEditUnlocked && draftFieldBlocks !== null - ? draftFieldBlocks - : undefined + draftFieldBlocks !== null ? draftFieldBlocks : undefined } policyMeta={state.customMethodCardMetaById?.[pendingCardId]} - showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked} + showPolicyContentLockupWhenNoBlocks={ + draftFieldBlocks === null || draftFieldBlocks.length === 0 + } onFieldBlocksChange={ - fieldsLocked + draftFieldBlocks === null ? undefined : (next) => setDraftFieldBlocks(next) } @@ -820,7 +769,6 @@ export function ConflictManagementScreen() { ) ) : null} @@ -829,6 +777,7 @@ export function ConflictManagementScreen() { uploadCreateFlowFile(file, "customMethodAttachment") diff --git a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx index 71b11e4..187ab40 100644 --- a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx +++ b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx @@ -51,20 +51,20 @@ import type { MembershipMethodDetailEntry } from "../../types"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; +import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; +import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import { captureMethodCardCustomizeSnapshot, 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 { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } = - useDiscardCustomizeConfirm(); + const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm(); const { state, updateState, replaceState, markCreateFlowInteraction } = useCreateFlow(); const pendingEphemeralDuplicateIdRef = useRef(null); @@ -77,12 +77,14 @@ export function MembershipMethodsScreen() { const [pendingDraft, setPendingDraft] = useState(null); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); - const [modalEditUnlocked, setModalEditUnlocked] = useState(false); + const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState< + string | null + >(null); + const [wizardInitialValues, setWizardInitialValues] = + useState(null); const [draftFieldBlocks, setDraftFieldBlocks] = useState< CustomMethodCardFieldBlock[] | null >(null); - const [customizeHeaderDraft, setCustomizeHeaderDraft] = - useState(null); const selectedIds = state.selectedMembershipMethodIds ?? []; @@ -105,6 +107,8 @@ export function MembershipMethodsScreen() { const handleOpenAddWizard = useCallback(() => { markCreateFlowInteraction(); + setWizardCustomizeCardId(null); + setWizardInitialValues(null); setAddCustomWizardOpen(true); }, [markCreateFlowInteraction]); @@ -142,15 +146,40 @@ export function MembershipMethodsScreen() { const handleCardClick = useCallback( (id: string) => { markCreateFlowInteraction(); - customizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + const draft = seedDraft(id); + const persistedBlocks = state.customMethodCardFieldBlocksById?.[id]; + const initialBlocks = + Array.isArray(persistedBlocks) && persistedBlocks.length > 0 + ? structuredClone(persistedBlocks) + : null; + const method = methodById.get(id); + const meta = state.customMethodCardMetaById?.[id]; + const headerDraft: MethodCardHeaderDraft = { + title: meta?.label ?? method?.label ?? mem.confirmModal.title, + description: + meta?.supportText ?? + method?.supportText ?? + mem.confirmModal.description, + }; + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + draft, + initialBlocks, + headerDraft, + ); setPendingCardId(id); - setPendingDraft(seedDraft(id)); + setPendingDraft(draft); + setDraftFieldBlocks(initialBlocks); setCreateModalOpen(true); }, - [markCreateFlowInteraction, seedDraft], + [ + mem.confirmModal.description, + mem.confirmModal.title, + markCreateFlowInteraction, + methodById, + seedDraft, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + ], ); const handleDraftChange = useCallback( @@ -163,9 +192,7 @@ export function MembershipMethodsScreen() { const isSelectedCardModal = pendingCardId !== null && selectedIds.includes(pendingCardId); - const fieldsLocked = !modalEditUnlocked; - - const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked; + const showMethodModalPrimary = true; const customFacetDetailsMatchPreset = useMemo(() => { if (!pendingCardId || !pendingDraft) return false; @@ -187,7 +214,7 @@ export function MembershipMethodsScreen() { methodId: pendingCardId, meta: state.customMethodCardMetaById, fieldBlocksById: state.customMethodCardFieldBlocksById, - modalEditUnlocked, + modalEditUnlocked: false, draftFieldBlocks, customFacetDetailsMatchPreset, }), @@ -195,7 +222,6 @@ export function MembershipMethodsScreen() { [ customFacetDetailsMatchPreset, draftFieldBlocks, - modalEditUnlocked, pendingCardId, state.customMethodCardFieldBlocksById, state.customMethodCardMetaById, @@ -205,11 +231,11 @@ export function MembershipMethodsScreen() { const handleCreateModalClose = useCallback(async () => { if ( !(await confirmDiscard( - modalEditUnlocked, + true, customizeSnapshotRef.current, pendingDraft, draftFieldBlocks, - customizeHeaderDraft, + customizeSnapshotRef.current?.headerDraft ?? null, )) ) { return; @@ -237,53 +263,14 @@ export function MembershipMethodsScreen() { setCreateModalOpen(false); setPendingCardId(null); setPendingDraft(null); - setModalEditUnlocked(false); setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); }, [ confirmDiscard, - customizeHeaderDraft, draftFieldBlocks, - modalEditUnlocked, pendingDraft, replaceState, ]); - const handleCancelCustomize = useCallback(async () => { - if (!modalEditUnlocked) { - return; - } - const snap = customizeSnapshotRef.current; - if (!snap) { - customizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); - return; - } - if ( - !(await confirmDirtyCustomizeCancel( - snap, - pendingDraft, - draftFieldBlocks, - customizeHeaderDraft, - )) - ) { - return; - } - setPendingDraft(structuredClone(snap.pendingDraft)); - setDraftFieldBlocks(null); - setModalEditUnlocked(false); - customizeSnapshotRef.current = null; - setCustomizeHeaderDraft(null); - }, [ - confirmDirtyCustomizeCancel, - customizeHeaderDraft, - draftFieldBlocks, - modalEditUnlocked, - pendingDraft, - ]); - const handleRemoveSelectedFromModal = useCallback(async () => { if (!pendingCardId || !selectedIds.includes(pendingCardId)) { return; @@ -291,11 +278,11 @@ export function MembershipMethodsScreen() { markCreateFlowInteraction(); if ( !(await confirmDiscard( - modalEditUnlocked, + true, customizeSnapshotRef.current, pendingDraft, draftFieldBlocks, - customizeHeaderDraft, + customizeSnapshotRef.current?.headerDraft ?? null, )) ) { return; @@ -307,11 +294,9 @@ export function MembershipMethodsScreen() { await handleCreateModalClose(); }, [ confirmDiscard, - customizeHeaderDraft, draftFieldBlocks, handleCreateModalClose, markCreateFlowInteraction, - modalEditUnlocked, pendingDraft, pendingCardId, selectedIds, @@ -321,35 +306,36 @@ export function MembershipMethodsScreen() { const handleCustomize = useCallback(() => { markCreateFlowInteraction(); - if (!pendingDraft || !pendingCardId) { + if (!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, + setWizardInitialValues( + buildMethodCardWizardInitialValues({ + cardId: pendingCardId, + fallbackTitle: method?.label ?? mem.confirmModal.title, + fallbackDescription: + method?.supportText ?? mem.confirmModal.description, + meta: state.customMethodCardMetaById, + persistedBlocks: state.customMethodCardFieldBlocksById, + draftFieldBlocks, + facetPrefill: pendingDraft + ? { + group: "membership", + draft: pendingDraft, + headings: mem.sectionHeadings, + } + : undefined, + }), ); - setDraftFieldBlocks(initialFieldBlocks); - setModalEditUnlocked(true); + setWizardCustomizeCardId(pendingCardId); + setCreateModalOpen(false); + setAddCustomWizardOpen(true); }, [ mem.confirmModal.description, mem.confirmModal.title, + mem.sectionHeadings, + draftFieldBlocks, markCreateFlowInteraction, methodById, pendingCardId, @@ -374,9 +360,7 @@ export function MembershipMethodsScreen() { () => membershipPresetFor(newId), ); const blocksClone = structuredClone( - modalEditUnlocked && - draftFieldBlocks !== null && - isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + draftFieldBlocks !== null ? draftFieldBlocks : cloneMethodCardBlocksForDuplicate( state.customMethodCardFieldBlocksById, @@ -406,13 +390,12 @@ export function MembershipMethodsScreen() { customizeSnapshotRef.current = null; setPendingCardId(newId); setPendingDraft(structuredClone(detailsClone)); - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + setDraftFieldBlocks( + blocksClone.length > 0 ? structuredClone(blocksClone) : null, + ); }, [ draftFieldBlocks, markCreateFlowInteraction, - modalEditUnlocked, modalKebabMenu.duplicateTitleSuffix, pendingCardId, pendingDraft, @@ -441,9 +424,7 @@ export function MembershipMethodsScreen() { () => membershipPresetFor(newId), ); const blocksClone = structuredClone( - modalEditUnlocked && - draftFieldBlocks !== null && - isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + draftFieldBlocks !== null ? draftFieldBlocks : cloneMethodCardBlocksForDuplicate( state.customMethodCardFieldBlocksById, @@ -473,14 +454,13 @@ export function MembershipMethodsScreen() { customizeSnapshotRef.current = null; setPendingCardId(newId); setPendingDraft(structuredClone(detailsClone)); - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + setDraftFieldBlocks( + blocksClone.length > 0 ? structuredClone(blocksClone) : null, + ); }, [ draftFieldBlocks, markCreateFlowInteraction, methodById, - modalEditUnlocked, modalKebabMenu.duplicateTitleSuffix, pendingCardId, pendingDraft, @@ -493,7 +473,7 @@ export function MembershipMethodsScreen() { const kebabMenuItems = useMemo( () => buildCustomRuleModalKebabMenu(modalKebabMenu, { - showCustomize: !modalEditUnlocked, + showCustomize: true, onCustomize: handleCustomize, onDuplicate: (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId @@ -513,7 +493,6 @@ export function MembershipMethodsScreen() { handleDuplicatePrefabCard, handleRemoveSelectedFromModal, isSelectedCardModal, - modalEditUnlocked, modalKebabMenu, pendingCardId, state.customMethodCardMetaById, @@ -532,7 +511,7 @@ export function MembershipMethodsScreen() { meta?.supportText ?? method?.supportText ?? mem.confirmModal.description, - nextButtonText: modalEditUnlocked + nextButtonText: isSelectedCardModal ? saveLabel : mem.addPlatform.nextButtonText, }; @@ -544,8 +523,14 @@ export function MembershipMethodsScreen() { }; const handleCloseAddWizard = useCallback(() => { + const resumeCardId = wizardCustomizeCardId; setAddCustomWizardOpen(false); - }, []); + setWizardCustomizeCardId(null); + setWizardInitialValues(null); + if (resumeCardId && pendingCardId === resumeCardId) { + setCreateModalOpen(true); + } + }, [pendingCardId, wizardCustomizeCardId]); const handleFinalizeCustomCard = useCallback( ({ @@ -558,6 +543,42 @@ export function MembershipMethodsScreen() { fieldBlocks: CustomMethodCardFieldBlock[]; }) => { markCreateFlowInteraction(); + const existingId = wizardCustomizeCardId; + if (existingId) { + updateState({ + selectedMembershipMethodIds: moveFacetSelectionIdToFront( + selectedIds, + existingId, + ), + customMethodCardMetaById: methodCardMetaWithCustomizeHeader( + state.customMethodCardMetaById, + existingId, + { title, description }, + ), + ...(pendingDraft + ? { + membershipMethodDetailsById: { + ...(state.membershipMethodDetailsById ?? {}), + [existingId]: pendingDraft, + }, + } + : {}), + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [existingId]: fieldBlocks, + }, + }); + if (pendingDraft) { + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + pendingDraft, + fieldBlocks, + { title, description }, + ); + } + setDraftFieldBlocks(structuredClone(fieldBlocks)); + setAddCustomWizardOpen(false); + return; + } const id = crypto.randomUUID(); updateState({ selectedMembershipMethodIds: moveFacetSelectionIdToFront( @@ -580,91 +601,29 @@ export function MembershipMethodsScreen() { }, [ markCreateFlowInteraction, + pendingDraft, selectedIds, state.customMethodCardFieldBlocksById, state.customMethodCardMetaById, state.membershipMethodDetailsById, updateState, + wizardCustomizeCardId, ], ); const handleCreateModalPrimary = useCallback(() => { if (!pendingCardId) { - handleCreateModalClose(); + void handleCreateModalClose(); return; } markCreateFlowInteraction(); - if (selectedIds.includes(pendingCardId)) { - 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; - } + const persistWizardBlocks = + modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null; - 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, - }) - ) { + if (selectedIds.includes(pendingCardId)) { + if (persistWizardBlocks) { updateState({ - customMethodCardMetaById: nextMeta, customMethodCardFieldBlocksById: { ...(state.customMethodCardFieldBlocksById ?? {}), [pendingCardId]: structuredClone(draftFieldBlocks ?? []), @@ -672,22 +631,27 @@ export function MembershipMethodsScreen() { }); } else if (pendingDraft) { updateState({ - customMethodCardMetaById: nextMeta, membershipMethodDetailsById: { ...(state.membershipMethodDetailsById ?? {}), [pendingCardId]: pendingDraft, }, }); } - customizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + if (pendingDraft) { + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + pendingDraft, + persistWizardBlocks ? draftFieldBlocks : null, + customizeSnapshotRef.current?.headerDraft ?? { + title: "", + description: "", + }, + ); + } return; } if (!pendingDraft) { - handleCreateModalClose(); + void handleCreateModalClose(); return; } updateState({ @@ -699,15 +663,23 @@ export function MembershipMethodsScreen() { ...(state.membershipMethodDetailsById ?? {}), [pendingCardId]: pendingDraft, }, + ...(persistWizardBlocks + ? { + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [pendingCardId]: structuredClone(draftFieldBlocks ?? []), + }, + } + : {}), }); pendingEphemeralDuplicateIdRef.current = null; - handleCreateModalClose(); + customizeSnapshotRef.current = null; + void handleCreateModalClose(); }, [ - customizeHeaderDraft, draftFieldBlocks, handleCreateModalClose, markCreateFlowInteraction, - modalEditUnlocked, + modalUsesWizardFieldBlocksBody, pendingCardId, pendingDraft, selectedIds, @@ -758,33 +730,10 @@ 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={modalEditUnlocked} - onBack={handleCancelCustomize} - backButtonText={modalKebabMenu.cancelCustomize} showNextButton={showMethodModalPrimary} backdropVariant="blurredYellow" kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} @@ -797,14 +746,14 @@ export function MembershipMethodsScreen() { cardId={pendingCardId} blocksById={state.customMethodCardFieldBlocksById} blocksOverride={ - modalEditUnlocked && draftFieldBlocks !== null - ? draftFieldBlocks - : undefined + draftFieldBlocks !== null ? draftFieldBlocks : undefined } policyMeta={state.customMethodCardMetaById?.[pendingCardId]} - showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked} + showPolicyContentLockupWhenNoBlocks={ + draftFieldBlocks === null || draftFieldBlocks.length === 0 + } onFieldBlocksChange={ - fieldsLocked + draftFieldBlocks === null ? undefined : (next) => setDraftFieldBlocks(next) } @@ -813,7 +762,6 @@ export function MembershipMethodsScreen() { ) ) : null} @@ -822,6 +770,7 @@ export function MembershipMethodsScreen() { uploadCreateFlowFile(file, "customMethodAttachment") diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx index 8a8b3f3..ab02d9e 100644 --- a/app/(app)/create/screens/review/FinalReviewScreen.tsx +++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx @@ -98,10 +98,10 @@ export function FinalReviewScreen({ * Two modals coexist on this screen: * * - {@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. + * Remove; values also offer Duplicate under the five-chip cap; method chips + * offer Customize (prefilled custom-policy wizard). Fields are editable on + * open. Save writes `{group}DetailsById` and field blocks; wizard Finalize + * also writes `customMethodCardMetaById`. * - {@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). diff --git a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx index db629be..69c3a76 100644 --- a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx +++ b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx @@ -51,20 +51,20 @@ import type { DecisionApproachDetailEntry } from "../../types"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; +import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; +import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import { captureMethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot, type MethodCardHeaderDraft, } from "../../../../../lib/create/methodCardCustomizeSession"; -import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader"; export function DecisionApproachesScreen() { const m = useMessages(); const da = m.create.customRule.decisionApproaches; const modalKebabMenu = m.create.customRule.modalKebabMenu; const mdUp = useCreateFlowMdUp(); - const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } = - useDiscardCustomizeConfirm(); + const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm(); const { state, updateState, replaceState, markCreateFlowInteraction } = useCreateFlow(); const pendingEphemeralDuplicateIdRef = useRef(null); @@ -80,12 +80,14 @@ export function DecisionApproachesScreen() { const [pendingDraft, setPendingDraft] = useState(null); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); - const [modalEditUnlocked, setModalEditUnlocked] = useState(false); + const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState< + string | null + >(null); + const [wizardInitialValues, setWizardInitialValues] = + useState(null); const [draftFieldBlocks, setDraftFieldBlocks] = useState< CustomMethodCardFieldBlock[] | null >(null); - const [customizeHeaderDraft, setCustomizeHeaderDraft] = - useState(null); const selectedIds = state.selectedDecisionApproachIds ?? []; @@ -117,6 +119,8 @@ export function DecisionApproachesScreen() { const handleOpenAddWizard = useCallback(() => { markCreateFlowInteraction(); + setWizardCustomizeCardId(null); + setWizardInitialValues(null); setAddCustomWizardOpen(true); }, [markCreateFlowInteraction]); @@ -158,15 +162,40 @@ export function DecisionApproachesScreen() { const handleCardSelect = useCallback( (id: string) => { markCreateFlowInteraction(); - customizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + const draft = seedDraft(id); + const persistedBlocks = state.customMethodCardFieldBlocksById?.[id]; + const initialBlocks = + Array.isArray(persistedBlocks) && persistedBlocks.length > 0 + ? structuredClone(persistedBlocks) + : null; + const method = methodById.get(id); + const meta = state.customMethodCardMetaById?.[id]; + const headerDraft: MethodCardHeaderDraft = { + title: meta?.label ?? method?.label ?? da.confirmModal.title, + description: + meta?.supportText ?? + method?.supportText ?? + da.confirmModal.description, + }; + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + draft, + initialBlocks, + headerDraft, + ); setPendingCardId(id); - setPendingDraft(seedDraft(id)); + setPendingDraft(draft); + setDraftFieldBlocks(initialBlocks); setCreateModalOpen(true); }, - [markCreateFlowInteraction, seedDraft], + [ + da.confirmModal.description, + da.confirmModal.title, + markCreateFlowInteraction, + methodById, + seedDraft, + state.customMethodCardFieldBlocksById, + state.customMethodCardMetaById, + ], ); const handleDraftChange = useCallback( @@ -179,9 +208,7 @@ export function DecisionApproachesScreen() { const isSelectedCardModal = pendingCardId !== null && selectedIds.includes(pendingCardId); - const fieldsLocked = !modalEditUnlocked; - - const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked; + const showMethodModalPrimary = true; const customFacetDetailsMatchPreset = useMemo(() => { if (!pendingCardId || !pendingDraft) return false; @@ -203,7 +230,7 @@ export function DecisionApproachesScreen() { methodId: pendingCardId, meta: state.customMethodCardMetaById, fieldBlocksById: state.customMethodCardFieldBlocksById, - modalEditUnlocked, + modalEditUnlocked: false, draftFieldBlocks, customFacetDetailsMatchPreset, }), @@ -211,7 +238,6 @@ export function DecisionApproachesScreen() { [ customFacetDetailsMatchPreset, draftFieldBlocks, - modalEditUnlocked, pendingCardId, state.customMethodCardFieldBlocksById, state.customMethodCardMetaById, @@ -221,11 +247,11 @@ export function DecisionApproachesScreen() { const handleCreateModalClose = useCallback(async () => { if ( !(await confirmDiscard( - modalEditUnlocked, + true, customizeSnapshotRef.current, pendingDraft, draftFieldBlocks, - customizeHeaderDraft, + customizeSnapshotRef.current?.headerDraft ?? null, )) ) { return; @@ -253,53 +279,14 @@ export function DecisionApproachesScreen() { setCreateModalOpen(false); setPendingCardId(null); setPendingDraft(null); - setModalEditUnlocked(false); setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); }, [ confirmDiscard, - customizeHeaderDraft, draftFieldBlocks, - modalEditUnlocked, pendingDraft, replaceState, ]); - const handleCancelCustomize = useCallback(async () => { - if (!modalEditUnlocked) { - return; - } - const snap = customizeSnapshotRef.current; - if (!snap) { - customizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); - return; - } - if ( - !(await confirmDirtyCustomizeCancel( - snap, - pendingDraft, - draftFieldBlocks, - customizeHeaderDraft, - )) - ) { - return; - } - setPendingDraft(structuredClone(snap.pendingDraft)); - setDraftFieldBlocks(null); - setModalEditUnlocked(false); - customizeSnapshotRef.current = null; - setCustomizeHeaderDraft(null); - }, [ - confirmDirtyCustomizeCancel, - customizeHeaderDraft, - draftFieldBlocks, - modalEditUnlocked, - pendingDraft, - ]); - const handleRemoveSelectedFromModal = useCallback(async () => { if (!pendingCardId || !selectedIds.includes(pendingCardId)) { return; @@ -307,11 +294,11 @@ export function DecisionApproachesScreen() { markCreateFlowInteraction(); if ( !(await confirmDiscard( - modalEditUnlocked, + true, customizeSnapshotRef.current, pendingDraft, draftFieldBlocks, - customizeHeaderDraft, + customizeSnapshotRef.current?.headerDraft ?? null, )) ) { return; @@ -327,11 +314,9 @@ export function DecisionApproachesScreen() { await handleCreateModalClose(); }, [ confirmDiscard, - customizeHeaderDraft, draftFieldBlocks, handleCreateModalClose, markCreateFlowInteraction, - modalEditUnlocked, pendingDraft, pendingCardId, selectedIds, @@ -341,35 +326,36 @@ export function DecisionApproachesScreen() { const handleCustomize = useCallback(() => { markCreateFlowInteraction(); - if (!pendingDraft || !pendingCardId) { + if (!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, + setWizardInitialValues( + buildMethodCardWizardInitialValues({ + cardId: pendingCardId, + fallbackTitle: method?.label ?? da.confirmModal.title, + fallbackDescription: + method?.supportText ?? da.confirmModal.description, + meta: state.customMethodCardMetaById, + persistedBlocks: state.customMethodCardFieldBlocksById, + draftFieldBlocks, + facetPrefill: pendingDraft + ? { + group: "decisionApproaches", + draft: pendingDraft, + headings: da.sectionHeadings, + } + : undefined, + }), ); - setDraftFieldBlocks(initialFieldBlocks); - setModalEditUnlocked(true); + setWizardCustomizeCardId(pendingCardId); + setCreateModalOpen(false); + setAddCustomWizardOpen(true); }, [ da.confirmModal.description, da.confirmModal.title, + da.sectionHeadings, + draftFieldBlocks, markCreateFlowInteraction, methodById, pendingCardId, @@ -394,9 +380,7 @@ export function DecisionApproachesScreen() { () => decisionApproachPresetFor(newId), ); const blocksClone = structuredClone( - modalEditUnlocked && - draftFieldBlocks !== null && - isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + draftFieldBlocks !== null ? draftFieldBlocks : cloneMethodCardBlocksForDuplicate( state.customMethodCardFieldBlocksById, @@ -426,13 +410,12 @@ export function DecisionApproachesScreen() { customizeSnapshotRef.current = null; setPendingCardId(newId); setPendingDraft(structuredClone(detailsClone)); - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + setDraftFieldBlocks( + blocksClone.length > 0 ? structuredClone(blocksClone) : null, + ); }, [ draftFieldBlocks, markCreateFlowInteraction, - modalEditUnlocked, modalKebabMenu.duplicateTitleSuffix, pendingCardId, pendingDraft, @@ -461,9 +444,7 @@ export function DecisionApproachesScreen() { () => decisionApproachPresetFor(newId), ); const blocksClone = structuredClone( - modalEditUnlocked && - draftFieldBlocks !== null && - isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) + draftFieldBlocks !== null ? draftFieldBlocks : cloneMethodCardBlocksForDuplicate( state.customMethodCardFieldBlocksById, @@ -493,14 +474,13 @@ export function DecisionApproachesScreen() { customizeSnapshotRef.current = null; setPendingCardId(newId); setPendingDraft(structuredClone(detailsClone)); - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + setDraftFieldBlocks( + blocksClone.length > 0 ? structuredClone(blocksClone) : null, + ); }, [ draftFieldBlocks, markCreateFlowInteraction, methodById, - modalEditUnlocked, modalKebabMenu.duplicateTitleSuffix, pendingCardId, pendingDraft, @@ -513,7 +493,7 @@ export function DecisionApproachesScreen() { const kebabMenuItems = useMemo( () => buildCustomRuleModalKebabMenu(modalKebabMenu, { - showCustomize: !modalEditUnlocked, + showCustomize: true, onCustomize: handleCustomize, onDuplicate: (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId @@ -533,7 +513,6 @@ export function DecisionApproachesScreen() { handleDuplicatePrefabCard, handleRemoveSelectedFromModal, isSelectedCardModal, - modalEditUnlocked, modalKebabMenu, pendingCardId, state.customMethodCardMetaById, @@ -547,8 +526,14 @@ export function DecisionApproachesScreen() { }, [markCreateFlowInteraction]); const handleCloseAddWizard = useCallback(() => { + const resumeCardId = wizardCustomizeCardId; setAddCustomWizardOpen(false); - }, []); + setWizardCustomizeCardId(null); + setWizardInitialValues(null); + if (resumeCardId && pendingCardId === resumeCardId) { + setCreateModalOpen(true); + } + }, [pendingCardId, wizardCustomizeCardId]); const handleFinalizeCustomCard = useCallback( ({ @@ -561,6 +546,42 @@ export function DecisionApproachesScreen() { fieldBlocks: CustomMethodCardFieldBlock[]; }) => { markCreateFlowInteraction(); + const existingId = wizardCustomizeCardId; + if (existingId) { + updateState({ + selectedDecisionApproachIds: moveFacetSelectionIdToFront( + selectedIds, + existingId, + ), + customMethodCardMetaById: methodCardMetaWithCustomizeHeader( + state.customMethodCardMetaById, + existingId, + { title, description }, + ), + ...(pendingDraft + ? { + decisionApproachDetailsById: { + ...(state.decisionApproachDetailsById ?? {}), + [existingId]: pendingDraft, + }, + } + : {}), + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [existingId]: fieldBlocks, + }, + }); + if (pendingDraft) { + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + pendingDraft, + fieldBlocks, + { title, description }, + ); + } + setDraftFieldBlocks(structuredClone(fieldBlocks)); + setAddCustomWizardOpen(false); + return; + } const id = crypto.randomUUID(); updateState({ selectedDecisionApproachIds: moveFacetSelectionIdToFront( @@ -583,91 +604,29 @@ export function DecisionApproachesScreen() { }, [ markCreateFlowInteraction, + pendingDraft, selectedIds, state.customMethodCardFieldBlocksById, state.customMethodCardMetaById, state.decisionApproachDetailsById, updateState, + wizardCustomizeCardId, ], ); const handleCreateModalPrimary = useCallback(() => { if (!pendingCardId) { - handleCreateModalClose(); + void handleCreateModalClose(); return; } markCreateFlowInteraction(); - if (selectedIds.includes(pendingCardId)) { - 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; - } - return; - } + const persistWizardBlocks = + modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null; - 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, - }) - ) { + if (selectedIds.includes(pendingCardId)) { + if (persistWizardBlocks) { updateState({ - customMethodCardMetaById: nextMeta, customMethodCardFieldBlocksById: { ...(state.customMethodCardFieldBlocksById ?? {}), [pendingCardId]: structuredClone(draftFieldBlocks ?? []), @@ -675,22 +634,27 @@ export function DecisionApproachesScreen() { }); } else if (pendingDraft) { updateState({ - customMethodCardMetaById: nextMeta, decisionApproachDetailsById: { ...(state.decisionApproachDetailsById ?? {}), [pendingCardId]: pendingDraft, }, }); } - customizeSnapshotRef.current = null; - setModalEditUnlocked(false); - setDraftFieldBlocks(null); - setCustomizeHeaderDraft(null); + if (pendingDraft) { + customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( + pendingDraft, + persistWizardBlocks ? draftFieldBlocks : null, + customizeSnapshotRef.current?.headerDraft ?? { + title: "", + description: "", + }, + ); + } return; } if (!pendingDraft) { - handleCreateModalClose(); + void handleCreateModalClose(); return; } updateState({ @@ -702,15 +666,23 @@ export function DecisionApproachesScreen() { ...(state.decisionApproachDetailsById ?? {}), [pendingCardId]: pendingDraft, }, + ...(persistWizardBlocks + ? { + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [pendingCardId]: structuredClone(draftFieldBlocks ?? []), + }, + } + : {}), }); pendingEphemeralDuplicateIdRef.current = null; - handleCreateModalClose(); + customizeSnapshotRef.current = null; + void handleCreateModalClose(); }, [ - customizeHeaderDraft, draftFieldBlocks, handleCreateModalClose, markCreateFlowInteraction, - modalEditUnlocked, + modalUsesWizardFieldBlocksBody, pendingCardId, pendingDraft, selectedIds, @@ -729,7 +701,7 @@ export function DecisionApproachesScreen() { meta?.supportText ?? method?.supportText ?? da.confirmModal.description, - nextButtonText: modalEditUnlocked + nextButtonText: isSelectedCardModal ? saveLabel : da.addApproach.nextButtonText, }; @@ -802,33 +774,10 @@ 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={modalEditUnlocked} - onBack={handleCancelCustomize} - backButtonText={modalKebabMenu.cancelCustomize} showNextButton={showMethodModalPrimary} backdropVariant="blurredYellow" kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} @@ -841,14 +790,14 @@ export function DecisionApproachesScreen() { cardId={pendingCardId} blocksById={state.customMethodCardFieldBlocksById} blocksOverride={ - modalEditUnlocked && draftFieldBlocks !== null - ? draftFieldBlocks - : undefined + draftFieldBlocks !== null ? draftFieldBlocks : undefined } policyMeta={state.customMethodCardMetaById?.[pendingCardId]} - showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked} + showPolicyContentLockupWhenNoBlocks={ + draftFieldBlocks === null || draftFieldBlocks.length === 0 + } onFieldBlocksChange={ - fieldsLocked + draftFieldBlocks === null ? undefined : (next) => setDraftFieldBlocks(next) } @@ -857,7 +806,6 @@ export function DecisionApproachesScreen() { ) ) : null} @@ -866,6 +814,7 @@ export function DecisionApproachesScreen() { uploadCreateFlowFile(file, "customMethodAttachment") diff --git a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx index 9c6ec6e..5732c74 100644 --- a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx +++ b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx @@ -16,13 +16,23 @@ import type { import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CoreValueEditFields } from "../../components/methodEditFields"; +import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; +import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; +import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { duplicateCoreValueChipInDraft, MAX_SELECTED_CORE_VALUES, removeCoreValueChipFromDraft, } from "../../../../../lib/create/coreValueChipFacet"; -import { omitIdFromStringRecord } from "../../../../../lib/create/duplicateMethodCardModalDraft"; +import { + buildMethodCardWizardInitialValues, + coreValueDetailsFromWizardFieldBlocks, + overlayFacetPrefillValues, + type MethodCardWizardInitialValues, +} from "../../../../../lib/create/methodCardWizardPrefill"; +import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; +import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES; @@ -113,6 +123,15 @@ export function CoreValuesSelectScreen() { ); const [modalSession, setModalSession] = useState(null); const [draft, setDraft] = useState(EMPTY_DETAIL); + const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); + const [wizardCustomizeChipId, setWizardCustomizeChipId] = useState< + string | null + >(null); + const [wizardInitialValues, setWizardInitialValues] = + useState(null); + const [draftFieldBlocks, setDraftFieldBlocks] = useState< + CustomMethodCardFieldBlock[] | null + >(null); useEffect(() => { setCoreValueOptions( @@ -165,6 +184,7 @@ export function CoreValuesSelectScreen() { return { meaning: saved?.meaning ?? preset.meaning, signals: saved?.signals ?? preset.signals, + ...(saved?.supportText ? { supportText: saved.supportText } : {}), }; }, [state.coreValueDetailsByChipId, getPresetTexts], @@ -179,12 +199,22 @@ export function CoreValuesSelectScreen() { ) => { const initial = seedDetail ?? getInitialTexts(chipId, valueLabel); initialDraftRef.current = { ...initial }; + const persisted = state.customMethodCardFieldBlocksById?.[chipId]; + setDraftFieldBlocks( + Array.isArray(persisted) && persisted.length > 0 + ? structuredClone(persisted) + : null, + ); setDraft(initial); setActiveModalChipId(chipId); setModalSession(session); markCreateFlowInteraction(); }, - [getInitialTexts, markCreateFlowInteraction], + [ + getInitialTexts, + markCreateFlowInteraction, + state.customMethodCardFieldBlocksById, + ], ); const handleDraftChange = useCallback( @@ -200,19 +230,19 @@ export function CoreValuesSelectScreen() { initialDraftRef.current = null; setActiveModalChipId(null); setModalSession(null); + setDraftFieldBlocks(null); }, []); const confirmLeaveWithoutSaving = useCallback(async () => { - const isPendingAdd = - modalSession === "pending" || modalSession === "customPending"; const initial = initialDraftRef.current; - const editingDirty = - modalSession === "editing" && + const fieldsDirty = initial != null && (draft.meaning !== initial.meaning || draft.signals !== initial.signals); - if (!isPendingAdd && !editingDirty) { + if (!fieldsDirty) { return true; } + const isPendingAdd = + modalSession === "pending" || modalSession === "customPending"; return requestConfirm({ title: cv.detailModal.discardTitle, description: isPendingAdd @@ -294,11 +324,7 @@ export function CoreValuesSelectScreen() { markCreateFlowInteraction(); replaceState((prev) => ({ ...prev, - selectedCoreValueIds: selectedIdsFromOptions(nextFiltered), - coreValuesChipsSnapshot: - chipOptionsToSnapshotRows(nextFiltered), - coreValueDetailsByChipId: - omitIdFromStringRecord(prev.coreValueDetailsByChipId, activeModalChipId), + ...removeCoreValueChipFromDraft(prev, activeModalChipId), })); setCoreValueOptions(nextFiltered); } @@ -355,48 +381,185 @@ export function CoreValuesSelectScreen() { if (!activeModalChipId || !modalSession) return; markCreateFlowInteraction(); pendingEphemeralCoreDuplicateRef.current = null; + const existingBlocks = + draftFieldBlocks && draftFieldBlocks.length > 0 + ? draftFieldBlocks + : state.customMethodCardFieldBlocksById?.[activeModalChipId]; updateState({ coreValueDetailsByChipId: { ...(state.coreValueDetailsByChipId ?? {}), [activeModalChipId]: draft, }, + ...(existingBlocks && existingBlocks.length > 0 + ? { + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [activeModalChipId]: overlayFacetPrefillValues(existingBlocks, { + group: "coreValues", + draft, + headings: { + meaning: cv.detailModal.meaningLabel, + signals: cv.detailModal.signalsLabel, + }, + }), + }, + } + : {}), }); finalizeModalDismiss(); }, [ activeModalChipId, + cv.detailModal.meaningLabel, + cv.detailModal.signalsLabel, draft, + draftFieldBlocks, finalizeModalDismiss, markCreateFlowInteraction, modalSession, state.coreValueDetailsByChipId, + state.customMethodCardFieldBlocksById, updateState, ]); const modalChipLabel = coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? ""; + const modalUsesWizardFieldBlocksBody = Boolean( + activeModalChipId && + usesWizardFieldBlocksModalBody({ + methodId: activeModalChipId, + meta: {}, + fieldBlocksById: state.customMethodCardFieldBlocksById, + modalEditUnlocked: true, + draftFieldBlocks, + }), + ); + const showFooterPrimary = Boolean(modalSession); + const handleCustomize = useCallback(() => { + if (!activeModalChipId) return; + markCreateFlowInteraction(); + setWizardInitialValues( + buildMethodCardWizardInitialValues({ + cardId: activeModalChipId, + fallbackTitle: modalChipLabel, + fallbackDescription: + draft.supportText?.trim() || cv.detailModal.subtitle, + meta: {}, + persistedBlocks: state.customMethodCardFieldBlocksById, + draftFieldBlocks, + facetPrefill: { + group: "coreValues", + draft, + headings: { + meaning: cv.detailModal.meaningLabel, + signals: cv.detailModal.signalsLabel, + }, + }, + }), + ); + setWizardCustomizeChipId(activeModalChipId); + setAddCustomWizardOpen(true); + }, [ + activeModalChipId, + cv.detailModal.meaningLabel, + cv.detailModal.signalsLabel, + cv.detailModal.subtitle, + draft, + draftFieldBlocks, + markCreateFlowInteraction, + modalChipLabel, + state.customMethodCardFieldBlocksById, + ]); + + const handleCloseAddWizard = useCallback(() => { + setAddCustomWizardOpen(false); + setWizardCustomizeChipId(null); + setWizardInitialValues(null); + }, []); + + const handleFinalizeCustomCard = useCallback( + ({ + title, + description, + fieldBlocks, + }: { + title: string; + description: string; + fieldBlocks: CustomMethodCardFieldBlock[]; + }) => { + const chipId = wizardCustomizeChipId ?? activeModalChipId; + if (!chipId) return; + markCreateFlowInteraction(); + pendingEphemeralCoreDuplicateRef.current = null; + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + const nextDetails = { + ...coreValueDetailsFromWizardFieldBlocks(fieldBlocks, draft), + ...(trimmedDescription.length > 0 + ? { supportText: trimmedDescription } + : {}), + }; + replaceState((prev) => { + const snap = [...(prev.coreValuesChipsSnapshot ?? [])]; + const i = snap.findIndex((r) => r.id === chipId); + if (i >= 0 && trimmedTitle.length > 0) { + snap[i] = { ...snap[i], label: trimmedTitle }; + } + return { + ...prev, + coreValuesChipsSnapshot: snap, + coreValueDetailsByChipId: { + ...(prev.coreValueDetailsByChipId ?? {}), + [chipId]: nextDetails, + }, + customMethodCardFieldBlocksById: { + ...(prev.customMethodCardFieldBlocksById ?? {}), + [chipId]: structuredClone(fieldBlocks), + }, + }; + }); + setDraft(nextDetails); + setDraftFieldBlocks(structuredClone(fieldBlocks)); + setAddCustomWizardOpen(false); + setWizardCustomizeChipId(null); + setWizardInitialValues(null); + }, + [ + activeModalChipId, + draft, + markCreateFlowInteraction, + replaceState, + wizardCustomizeChipId, + ], + ); + const kebabMenuItems = useMemo(() => { if (!modalSession || !activeModalChipId) return []; const selectedCount = coreValueOptions.filter( (o) => o.state === "selected", ).length; return buildCustomRuleModalKebabMenu(modalKebabMenu, { + showCustomize: true, + onCustomize: handleCustomize, onDuplicate: - modalSession !== "editing" || selectedCount >= MAX_CORE_VALUES + (state.editingPublishedRuleId?.trim() ?? "") !== "" || + selectedCount >= MAX_CORE_VALUES ? undefined : handleDuplicateCoreChip, - showRemove: true, + showRemove: modalSession === "editing", onRemove: handleRemoveFromKebab, }); }, [ activeModalChipId, coreValueOptions, + handleCustomize, handleDuplicateCoreChip, handleRemoveFromKebab, modalKebabMenu, modalSession, + state.editingPublishedRuleId, ]); const handleChipClick = (chipId: string) => { const target = coreValueOptions.find((o) => o.id === chipId); @@ -520,14 +683,16 @@ export function CoreValuesSelectScreen() { {detailModal && ( @@ -548,13 +713,43 @@ export function CoreValuesSelectScreen() { } ariaLabel={modalChipLabel || "Core value details"} > - + {modalUsesWizardFieldBlocksBody && activeModalChipId ? ( + { + setDraftFieldBlocks(next); + setDraft( + coreValueDetailsFromWizardFieldBlocks(next, draft), + ); + } + } + /> + ) : ( + + )} )} + + uploadCreateFlowFile(file, "customMethodAttachment") + } + /> {confirmDialog} ); diff --git a/app/(app)/create/types.ts b/app/(app)/create/types.ts index e2a3a14..02e9071 100644 --- a/app/(app)/create/types.ts +++ b/app/(app)/create/types.ts @@ -59,6 +59,7 @@ export type CommunityStructureChipSnapshotRow = { export type CoreValueDetailEntry = { meaning: string; signals: string; + supportText?: string; }; /** diff --git a/app/(app)/create/utils/coreValueDetailsLocalStorage.ts b/app/(app)/create/utils/coreValueDetailsLocalStorage.ts index a62e5c4..de9a240 100644 --- a/app/(app)/create/utils/coreValueDetailsLocalStorage.ts +++ b/app/(app)/create/utils/coreValueDetailsLocalStorage.ts @@ -21,7 +21,13 @@ export function readCoreValueDetailsFromLocalStorage(): Record< if (typeof o.meaning !== "string" || typeof o.signals !== "string") { continue; } - out[k] = { meaning: o.meaning, signals: o.signals }; + out[k] = { + meaning: o.meaning, + signals: o.signals, + ...(typeof o.supportText === "string" + ? { supportText: o.supportText } + : {}), + }; } return out; } catch { diff --git a/lib/create/applyFinalReviewChipEditPatch.ts b/lib/create/applyFinalReviewChipEditPatch.ts index 24944cc..b7df204 100644 --- a/lib/create/applyFinalReviewChipEditPatch.ts +++ b/lib/create/applyFinalReviewChipEditPatch.ts @@ -69,7 +69,6 @@ export function applyFinalReviewChipEditPatch( } : {}; if ( - patch.groupKey !== "coreValues" && "customMethodCardFieldBlocks" in patch && patch.customMethodCardFieldBlocks !== undefined ) { diff --git a/lib/create/coreValueChipFacet.ts b/lib/create/coreValueChipFacet.ts index 25bf141..968447c 100644 --- a/lib/create/coreValueChipFacet.ts +++ b/lib/create/coreValueChipFacet.ts @@ -24,6 +24,15 @@ export function removeCoreValueChipFromDraft( const nextDetails = hadDetail ? omitIdFromStringRecord(state.coreValueDetailsByChipId, chipId) : undefined; + const hadBlocks = + Boolean(state.customMethodCardFieldBlocksById) && + Object.prototype.hasOwnProperty.call( + state.customMethodCardFieldBlocksById, + chipId, + ); + const nextBlocks = hadBlocks + ? omitIdFromStringRecord(state.customMethodCardFieldBlocksById, chipId) + : undefined; const out: Partial = { coreValuesChipsSnapshot: nextSnap, @@ -33,6 +42,9 @@ export function removeCoreValueChipFromDraft( if (hadDetail) { out.coreValueDetailsByChipId = nextDetails; } + if (hadBlocks) { + out.customMethodCardFieldBlocksById = nextBlocks; + } return out; } @@ -77,6 +89,7 @@ export function duplicateCoreValueChipInDraft( [newId]: structuredClone(inherited), } : { ...(state.coreValueDetailsByChipId ?? {}) }; + const inheritedBlocks = state.customMethodCardFieldBlocksById?.[chipId]; return { newId, @@ -87,6 +100,14 @@ export function duplicateCoreValueChipInDraft( ...(Object.keys(nextDetails).length > 0 ? { coreValueDetailsByChipId: nextDetails } : {}), + ...(inheritedBlocks !== undefined + ? { + customMethodCardFieldBlocksById: { + ...(state.customMethodCardFieldBlocksById ?? {}), + [newId]: structuredClone(inheritedBlocks), + }, + } + : {}), }, }; } diff --git a/lib/create/customMethodCardWizardConstants.ts b/lib/create/customMethodCardWizardConstants.ts index dcec45f..290db57 100644 --- a/lib/create/customMethodCardWizardConstants.ts +++ b/lib/create/customMethodCardWizardConstants.ts @@ -1,2 +1,8 @@ -/** Max length for title and description fields in the add-custom-method-card wizard (Figma 0/48). */ +/** Max length for the policy title in the add-custom-method-card wizard (Figma 0/48). */ export const CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS = 48; + +/** + * Max length for the policy description. Wider than the title so Customize can + * seed existing card support text without blocking Next. + */ +export const CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS = 200; diff --git a/lib/create/methodCardWizardPrefill.ts b/lib/create/methodCardWizardPrefill.ts new file mode 100644 index 0000000..d81cf4f --- /dev/null +++ b/lib/create/methodCardWizardPrefill.ts @@ -0,0 +1,391 @@ +import type { + CommunicationMethodDetailEntry, + ConflictManagementDetailEntry, + CoreValueDetailEntry, + CreateFlowState, + DecisionApproachDetailEntry, + MembershipMethodDetailEntry, +} from "../../app/(app)/create/types"; +import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks"; +import { formatConflictApplicableScopeForTextarea } from "./ruleSectionsFromMethodSelections"; + +/** Seed for {@link CustomMethodCardWizard} when Customize opens on an existing card. */ +export type MethodCardWizardInitialValues = { + title: string; + description: string; + fieldBlocks: CustomMethodCardFieldBlock[]; +}; + +export type MethodCardWizardFacetPrefill = + | { + group: "communication"; + draft: CommunicationMethodDetailEntry; + headings: { + corePrinciple: string; + logisticsAdmin: string; + codeOfConduct: string; + }; + } + | { + group: "membership"; + draft: MembershipMethodDetailEntry; + headings: { + eligibility: string; + joiningProcess: string; + expectations: string; + }; + } + | { + group: "decisionApproaches"; + draft: DecisionApproachDetailEntry; + headings: { + corePrinciple: string; + applicableScope: string; + stepByStepInstructions: string; + consensusLevel: string; + objectionsDeadlocks: string; + }; + } + | { + group: "conflictManagement"; + draft: ConflictManagementDetailEntry; + headings: { + corePrinciple: string; + applicableScope: string; + processProtocol: string; + restorationFallbacks: string; + }; + } + | { + group: "coreValues"; + draft: CoreValueDetailEntry; + headings: { + meaning: string; + signals: string; + }; + }; + +const TEXT_BODY_MAX = 8000; + +function textBlock( + id: string, + blockTitle: string, + placeholderText: string, +): CustomMethodCardFieldBlock { + return { + kind: "text", + id, + blockTitle, + placeholderText: placeholderText.slice(0, TEXT_BODY_MAX), + }; +} + +function hasTrimmedText(value: string): boolean { + return value.trim().length > 0; +} + +function clampPercent(n: number): number { + if (!Number.isFinite(n)) return 1; + return Math.min(100, Math.max(1, Math.round(n))); +} + +function facetPrefillHasContent(prefill: MethodCardWizardFacetPrefill): boolean { + switch (prefill.group) { + case "communication": + return ( + hasTrimmedText(prefill.draft.corePrinciple) || + hasTrimmedText(prefill.draft.logisticsAdmin) || + hasTrimmedText(prefill.draft.codeOfConduct) + ); + case "membership": + return ( + hasTrimmedText(prefill.draft.eligibility) || + hasTrimmedText(prefill.draft.joiningProcess) || + hasTrimmedText(prefill.draft.expectations) + ); + case "decisionApproaches": + return ( + hasTrimmedText(prefill.draft.corePrinciple) || + hasTrimmedText(prefill.draft.stepByStepInstructions) || + hasTrimmedText(prefill.draft.objectionsDeadlocks) || + prefill.draft.applicableScope.length > 0 || + prefill.draft.selectedApplicableScope.length > 0 + ); + case "conflictManagement": + return ( + hasTrimmedText(prefill.draft.corePrinciple) || + hasTrimmedText(prefill.draft.processProtocol) || + hasTrimmedText(prefill.draft.restorationFallbacks) || + formatConflictApplicableScopeForTextarea( + prefill.draft.selectedApplicableScope, + prefill.draft.applicableScope, + ).trim().length > 0 + ); + case "coreValues": + return ( + hasTrimmedText(prefill.draft.meaning) || + hasTrimmedText(prefill.draft.signals) + ); + } +} + +function mapFacetPrefillToWizardFieldBlocks( + prefill: MethodCardWizardFacetPrefill, +): CustomMethodCardFieldBlock[] { + switch (prefill.group) { + case "communication": + return [ + textBlock( + "facet-corePrinciple", + prefill.headings.corePrinciple, + prefill.draft.corePrinciple, + ), + textBlock( + "facet-logisticsAdmin", + prefill.headings.logisticsAdmin, + prefill.draft.logisticsAdmin, + ), + textBlock( + "facet-codeOfConduct", + prefill.headings.codeOfConduct, + prefill.draft.codeOfConduct, + ), + ]; + case "membership": + return [ + textBlock( + "facet-eligibility", + prefill.headings.eligibility, + prefill.draft.eligibility, + ), + textBlock( + "facet-joiningProcess", + prefill.headings.joiningProcess, + prefill.draft.joiningProcess, + ), + textBlock( + "facet-expectations", + prefill.headings.expectations, + prefill.draft.expectations, + ), + ]; + case "decisionApproaches": { + const scopeOptions = + prefill.draft.applicableScope.length > 0 + ? [...prefill.draft.applicableScope] + : [...prefill.draft.selectedApplicableScope]; + const blocks: CustomMethodCardFieldBlock[] = [ + textBlock( + "facet-corePrinciple", + prefill.headings.corePrinciple, + prefill.draft.corePrinciple, + ), + ]; + if (scopeOptions.length > 0) { + blocks.push({ + kind: "badges", + id: "facet-applicableScope", + blockTitle: prefill.headings.applicableScope, + options: scopeOptions, + }); + } + blocks.push( + textBlock( + "facet-stepByStepInstructions", + prefill.headings.stepByStepInstructions, + prefill.draft.stepByStepInstructions, + ), + { + kind: "proportion", + id: "facet-consensusLevel", + blockTitle: prefill.headings.consensusLevel, + defaultPercent: clampPercent(prefill.draft.consensusLevel), + }, + textBlock( + "facet-objectionsDeadlocks", + prefill.headings.objectionsDeadlocks, + prefill.draft.objectionsDeadlocks, + ), + ); + return blocks; + } + case "conflictManagement": + return [ + textBlock( + "facet-corePrinciple", + prefill.headings.corePrinciple, + prefill.draft.corePrinciple, + ), + textBlock( + "facet-applicableScope", + prefill.headings.applicableScope, + formatConflictApplicableScopeForTextarea( + prefill.draft.selectedApplicableScope, + prefill.draft.applicableScope, + ), + ), + textBlock( + "facet-processProtocol", + prefill.headings.processProtocol, + prefill.draft.processProtocol, + ), + textBlock( + "facet-restorationFallbacks", + prefill.headings.restorationFallbacks, + prefill.draft.restorationFallbacks, + ), + ]; + case "coreValues": + return [ + textBlock( + "facet-meaning", + prefill.headings.meaning, + prefill.draft.meaning, + ), + textBlock( + "facet-signals", + prefill.headings.signals, + prefill.draft.signals, + ), + ]; + } +} + +/** + * Map a method card's facet editors onto wizard field blocks so Customize + * can show and edit the same sections on step 3. + */ +export function facetDetailsToWizardFieldBlocks( + prefill: MethodCardWizardFacetPrefill, +): CustomMethodCardFieldBlock[] { + if (!facetPrefillHasContent(prefill)) { + return []; + } + return mapFacetPrefillToWizardFieldBlocks(prefill); +} + +function facetBlockHasContent(block: CustomMethodCardFieldBlock): boolean { + switch (block.kind) { + case "text": + return hasTrimmedText(block.placeholderText); + case "badges": + return block.options.length > 0; + case "proportion": + return true; + case "upload": + return Boolean(block.fileName?.trim() || block.assetUrl?.trim()); + } +} + +function overlayBlockValue( + existing: CustomMethodCardFieldBlock, + incoming: CustomMethodCardFieldBlock, +): CustomMethodCardFieldBlock { + if (existing.kind === "text" && incoming.kind === "text") { + return { ...existing, placeholderText: incoming.placeholderText }; + } + if (existing.kind === "badges" && incoming.kind === "badges") { + return { ...existing, options: [...incoming.options] }; + } + if (existing.kind === "proportion" && incoming.kind === "proportion") { + return { ...existing, defaultPercent: incoming.defaultPercent }; + } + if (existing.kind === "upload" && incoming.kind === "upload") { + return { + ...existing, + fileName: incoming.fileName, + ...(incoming.assetUrl !== undefined + ? { assetUrl: incoming.assetUrl } + : {}), + }; + } + return incoming; +} + +/** + * Keep extra wizard-only blocks, but refresh facet-* field values from the + * current modal editors so Customize does not show a stale copy. + */ +export function overlayFacetPrefillValues( + blocks: CustomMethodCardFieldBlock[], + prefill: MethodCardWizardFacetPrefill, +): CustomMethodCardFieldBlock[] { + const incoming = mapFacetPrefillToWizardFieldBlocks(prefill); + const incomingById = new Map(incoming.map((block) => [block.id, block])); + const seen = new Set(); + const next = blocks.map((block) => { + const overlay = incomingById.get(block.id); + if (!overlay) { + return block; + } + seen.add(block.id); + return overlayBlockValue(block, overlay); + }); + for (const block of incoming) { + if (seen.has(block.id) || !facetBlockHasContent(block)) { + continue; + } + next.push(block); + } + return next; +} + +function textPlaceholderForBlockId( + blocks: CustomMethodCardFieldBlock[], + id: string, +): string | undefined { + const block = blocks.find((b) => b.id === id); + if (!block || block.kind !== "text") { + return undefined; + } + return block.placeholderText; +} + +/** Map Customize wizard field blocks back onto meaning/signals for a value chip. */ +export function coreValueDetailsFromWizardFieldBlocks( + blocks: CustomMethodCardFieldBlock[], + fallback: CoreValueDetailEntry, +): CoreValueDetailEntry { + return { + meaning: textPlaceholderForBlockId(blocks, "facet-meaning") ?? fallback.meaning, + signals: + textPlaceholderForBlockId(blocks, "facet-signals") ?? fallback.signals, + ...(fallback.supportText !== undefined + ? { supportText: fallback.supportText } + : {}), + }; +} + +/** + * Title, support text, and field blocks for the custom-policy wizard, preferring + * in-modal drafts over persisted meta/blocks. When the card has no wizard + * blocks yet, facet body fields are mapped onto step-3 blocks. + */ +export function buildMethodCardWizardInitialValues(args: { + cardId: string; + fallbackTitle: string; + fallbackDescription: string; + meta: CreateFlowState["customMethodCardMetaById"]; + persistedBlocks: CreateFlowState["customMethodCardFieldBlocksById"]; + draftFieldBlocks: CustomMethodCardFieldBlock[] | null; + facetPrefill?: MethodCardWizardFacetPrefill; +}): MethodCardWizardInitialValues { + const metaRow = args.meta?.[args.cardId]; + const persisted = args.persistedBlocks?.[args.cardId]; + let fieldBlocks = + args.draftFieldBlocks !== null + ? structuredClone(args.draftFieldBlocks) + : Array.isArray(persisted) && persisted.length > 0 + ? structuredClone(persisted) + : []; + if (fieldBlocks.length === 0 && args.facetPrefill) { + fieldBlocks = facetDetailsToWizardFieldBlocks(args.facetPrefill); + } else if (fieldBlocks.length > 0 && args.facetPrefill) { + fieldBlocks = overlayFacetPrefillValues(fieldBlocks, args.facetPrefill); + } + return { + title: metaRow?.label ?? args.fallbackTitle, + description: metaRow?.supportText ?? args.fallbackDescription, + fieldBlocks, + }; +} diff --git a/lib/create/usesWizardFieldBlocksModalBody.ts b/lib/create/usesWizardFieldBlocksModalBody.ts index 3ea47ee..0205ab0 100644 --- a/lib/create/usesWizardFieldBlocksModalBody.ts +++ b/lib/create/usesWizardFieldBlocksModalBody.ts @@ -14,6 +14,9 @@ import { isCustomMethodCardId } from "./isCustomMethodCardId"; * stubs keep the facet's structured edit fields until the user adds blocks (then this * returns true once persisted blocks are non-empty). * + * Non-empty **draft** blocks also win, including catalog ids after Customize + * Finalize (facet editors ignore array order). + * * **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 @@ -33,16 +36,15 @@ export function usesWizardFieldBlocksModalBody(args: { 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; } + if (!isCustomMethodCardId(args.methodId, args.meta)) { + return false; + } return ( !args.modalEditUnlocked && args.customFacetDetailsMatchPreset === true ); diff --git a/lib/server/validation/createFlowSchemas.ts b/lib/server/validation/createFlowSchemas.ts index bf3a4d2..2f0c044 100644 --- a/lib/server/validation/createFlowSchemas.ts +++ b/lib/server/validation/createFlowSchemas.ts @@ -25,6 +25,7 @@ const communityStructureChipSnapshotsSchema = z const coreValueDetailEntrySchema = z.object({ meaning: z.string().max(8000), signals: z.string().max(8000), + supportText: z.string().max(2000).optional(), }); /** diff --git a/messages/en/create/customRule/customMethodCardWizard.json b/messages/en/create/customRule/customMethodCardWizard.json index 5720294..81bd65a 100644 --- a/messages/en/create/customRule/customMethodCardWizard.json +++ b/messages/en/create/customRule/customMethodCardWizard.json @@ -46,6 +46,7 @@ }, "fieldModals": { "addField": "Add field", + "saveField": "Save field", "requiredHint": "Required", "text": { "title": "Add text block", diff --git a/tests/components/CommunicationMethodsScreenPersistence.test.tsx b/tests/components/CommunicationMethodsScreenPersistence.test.tsx index 7d2f5ba..10af42f 100644 --- a/tests/components/CommunicationMethodsScreenPersistence.test.tsx +++ b/tests/components/CommunicationMethodsScreenPersistence.test.tsx @@ -75,20 +75,16 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => { ); 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(screen.getByRole("dialog")).getAllByRole("textbox"); - expect(textboxes.length).toBe(5); - const corePrincipleField = textboxes[2] as HTMLTextAreaElement; + const textboxes = within(dialog).getAllByRole("textbox"); + expect(textboxes.length).toBe(3); + const corePrincipleField = textboxes[0] 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(screen.getByRole("dialog")).getByRole("button", { + within(dialog).getByRole("button", { name: "Add Platform", }), ); @@ -162,7 +158,30 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => { expect(textareas[2].value).toBe("Saved coc"); }); - it("Cancel customize reverts edited preset without persisting (no confirm when unchanged)", async () => { + it("opens meaning fields editable without Customize", async () => { + render( + { + /* noop */ + }} + />, + ); + + fireEvent.click( + screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0], + ); + const dialog = await screen.findByRole("dialog"); + const textareas = within(dialog).getAllByRole( + "textbox", + ) as HTMLTextAreaElement[]; + expect(textareas[0]).not.toBeDisabled(); + fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); + expect( + screen.getByRole("menuitem", { name: "Customize" }), + ).toBeInTheDocument(); + }); + + it("closing the wizard without Finalize leaves the card modal and does not persist", async () => { let latest: CreateFlowState = {}; render( { 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( + await screen.findByPlaceholderText("Policy name"), + ).toBeInTheDocument(); + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => { - expect(screen.getByRole("dialog")).toBeInTheDocument(); - expect( - (within(screen.getByRole("dialog")).getAllByRole( - "textbox", - )[0] as HTMLTextAreaElement).disabled, - ).toBe(true); + expect(screen.queryByPlaceholderText("Policy name")).not.toBeInTheDocument(); }); - expect(latest.communicationMethodDetailsById).toBeUndefined(); - expect(screen.queryByRole("button", { name: "Discard" })).not.toBeInTheDocument(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(latest.customMethodCardMetaById).toBeUndefined(); }); - it("Cancel customize with edits restores snapshot after confirm", async () => { + it("Back from the wizard with edits asks to discard and does not persist", 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 nameInput = await screen.findByPlaceholderText("Policy name"); + fireEvent.change(nameInput, { target: { value: "Renamed in wizard" } }); + fireEvent.click(screen.getByRole("button", { name: "Back" })); + + expect( + await screen.findByRole("button", { name: "Keep editing" }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Discard" })); + + await waitFor(() => { + expect(screen.queryByPlaceholderText("Policy name")).not.toBeInTheDocument(); + }); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(latest.customMethodCardMetaById).toBeUndefined(); + }); + + it("Escape after a field edit stays open when user declines discard confirm", async () => { + render( + { + /* noop */ + }} + />, + ); + + fireEvent.click( + screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0], + ); + const dialog = await screen.findByRole("dialog"); + const textboxes = within(dialog).getAllByRole( + "textbox", + ) as HTMLTextAreaElement[]; + fireEvent.change(textboxes[0], { target: { value: "Edited principle" } }); + + fireEvent.keyDown(document, { key: "Escape" }); + const keepEditing = await screen.findByRole("button", { + name: "Keep editing", + }); + expect(keepEditing.parentElement).toHaveClass( + "absolute", + "left-[16px]", + "top-[12px]", + ); + await declineDiscardCustomizeEdits(); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("Escape after a field edit discards without persisting when confirmed", async () => { let latest: CreateFlowState = {}; render( { 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" })); + fireEvent.change(textboxes[0], { target: { value: "Edited principle" } }); + fireEvent.keyDown(document, { key: "Escape" }); await confirmDiscardCustomizeEdits(); await waitFor(() => { - expect( - ( - within(screen.getByRole("dialog")).getAllByRole( - "textbox", - )[0] as HTMLTextAreaElement - ).value, - ).toBe("Saved principle"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); expect( latest.communicationMethodDetailsById?.signal?.corePrinciple, ).toBe("Saved principle"); }); - it("dirty Escape close stays open when user declines discard confirm", async () => { - 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" }); - const keepEditing = await screen.findByRole("button", { - name: "Keep editing", - }); - expect(keepEditing.parentElement).toHaveClass( - "absolute", - "left-[16px]", - "top-[12px]", - ); - await declineDiscardCustomizeEdits(); - - expect(screen.getByRole("dialog")).toBeInTheDocument(); - }); - - it("persists customized policy title for a custom UUID card on Save", async () => { + it("Customize wizard prefills the card title and persists a rename on Finalize", async () => { const customId = "00000000-0000-4000-8000-0000000000aa"; let latest: CreateFlowState = {}; render( @@ -307,13 +345,21 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => { 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" }), - ); + expect( + await screen.findByPlaceholderText("Policy name"), + ).toBeInTheDocument(); + const nameInput = screen.getByPlaceholderText("Policy name"); + expect(nameInput).toHaveValue("Original title"); + fireEvent.change(nameInput, { target: { value: "Renamed policy" } }); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect( + await screen.findByText("Custom policy details"), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Core Principle & Scope" }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Finalize" })); await waitFor(() => { expect(latest.customMethodCardMetaById?.[customId]?.label).toBe( @@ -322,7 +368,7 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => { }); }); - it("stores preset id title override in customMethodCardMetaById on Save", async () => { + it("stores preset id title override in customMethodCardMetaById on Finalize", async () => { let latest: CreateFlowState = {}; render( { 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, { + expect( + await screen.findByPlaceholderText("Policy name"), + ).toHaveValue("Signal"); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect( + await screen.findByText("Custom policy details"), + ).toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "Core Principle & Scope" }), + ); + expect(await screen.findByText("Add text block")).toBeInTheDocument(); + expect( + screen.getByDisplayValue(/We prioritize privacy and security/i), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Back" })); + fireEvent.click(screen.getByRole("button", { name: "Back" })); + fireEvent.click(screen.getByRole("button", { name: "Back" })); + const nameInput = await screen.findByPlaceholderText("Policy name"); + expect(nameInput).toHaveValue("Signal"); + fireEvent.change(nameInput, { target: { value: "Custom Signal header" }, }); - fireEvent.click( - within(screen.getByRole("dialog")).getByRole("button", { name: "Save" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Finalize" })); await waitFor(() => { expect(latest.customMethodCardMetaById?.signal?.label).toBe( @@ -355,4 +418,79 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => { ); }); }); + + it("Customize Finalize shows reordered field blocks in the card modal", async () => { + let latest: CreateFlowState = {}; + render( + { + latest = s; + }} + />, + ); + + fireEvent.click( + screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0], + ); + const cardDialog = await screen.findByRole("dialog"); + fireEvent.click( + within(cardDialog).getByRole("button", { name: "More options" }), + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); + + fireEvent.click(await screen.findByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect( + await screen.findByText("Custom policy details"), + ).toBeInTheDocument(); + + const handles = screen.getAllByRole("button", { + name: "Drag to reorder this field", + }); + const rows = screen.getAllByRole("listitem"); + const store: Record = {}; + const dataTransfer = { + effectAllowed: "all", + dropEffect: "move", + setData(type: string, value: string) { + store[type] = value; + }, + getData(type: string) { + return store[type] ?? ""; + }, + }; + fireEvent.pointerDown(handles[0]); + fireEvent.dragStart(rows[0], { dataTransfer }); + fireEvent.drop(rows[2], { dataTransfer }); + + fireEvent.click(screen.getByRole("button", { name: "Finalize" })); + + await waitFor(() => { + expect( + screen.queryByPlaceholderText("Policy name"), + ).not.toBeInTheDocument(); + }); + await waitFor(() => { + expect( + latest.customMethodCardFieldBlocksById?.signal?.map((b) => b.id), + ).toEqual([ + "facet-logisticsAdmin", + "facet-codeOfConduct", + "facet-corePrinciple", + ]); + }); + + const result = screen.getByRole("dialog"); + const labels = within(result) + .getAllByRole("textbox") + .map((el) => { + const labelledby = el.getAttribute("aria-labelledby"); + return labelledby + ? (document.getElementById(labelledby)?.textContent ?? "").trim() + : ""; + }); + expect(labels[0]).toMatch(/Logistics, Admin/); + expect(labels[1]).toMatch(/Code of Conduct/); + expect(labels[2]).toMatch(/Core Principle/); + }); }); diff --git a/tests/components/CoreValuesSelectScreen.test.tsx b/tests/components/CoreValuesSelectScreen.test.tsx index 007f425..fa7e044 100644 --- a/tests/components/CoreValuesSelectScreen.test.tsx +++ b/tests/components/CoreValuesSelectScreen.test.tsx @@ -13,6 +13,12 @@ describe("CoreValuesSelectScreen", () => { fireEvent.click(await screen.findByRole("button", { name: "Discard" })); } + async function editMeaningInOpenDialog(next: string) { + const dialog = await screen.findByRole("dialog"); + const fields = within(dialog).getAllByRole("textbox"); + fireEvent.change(fields[0], { target: { value: next } }); + } + it("opens core value detail modal when a preset chip is clicked", async () => { renderWithProviders(); fireEvent.click(screen.getByText("Accessibility")); @@ -20,20 +26,36 @@ describe("CoreValuesSelectScreen", () => { expect( within(dialog).getByRole("button", { name: "Add Value" }), ).toBeInTheDocument(); - expect( - screen.queryByRole("menuitem", { name: "Customize" }), - ).not.toBeInTheDocument(); fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); expect( - screen.queryByRole("menuitem", { name: "Customize" }), + screen.getByRole("menuitem", { name: "Customize" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: "Duplicate" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: "Remove" }), ).not.toBeInTheDocument(); }); - it("asks to discard when closing a pending value, then unselects on Discard", async () => { + it("closes a pending value without confirm when fields are unchanged", async () => { renderWithProviders(); fireEvent.click(screen.getByText("Accessibility")); await screen.findByRole("dialog"); fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + expect( + screen.queryByRole("button", { name: "Keep editing" }), + ).not.toBeInTheDocument(); + }); + + it("asks to discard when closing a pending value after an edit", async () => { + renderWithProviders(); + fireEvent.click(screen.getByText("Accessibility")); + await editMeaningInOpenDialog("Changed meaning"); + fireEvent.keyDown(document, { key: "Escape" }); expect( await screen.findByRole("button", { name: "Keep editing" }), ).toBeInTheDocument(); @@ -46,7 +68,7 @@ describe("CoreValuesSelectScreen", () => { it("keeps the pending value modal open when Keep editing is chosen", async () => { renderWithProviders(); fireEvent.click(screen.getByText("Accessibility")); - await screen.findByRole("dialog"); + await editMeaningInOpenDialog("Changed meaning"); fireEvent.keyDown(document, { key: "Escape" }); fireEvent.click(await screen.findByRole("button", { name: "Keep editing" })); const dialog = await screen.findByRole("dialog"); @@ -78,6 +100,162 @@ describe("CoreValuesSelectScreen", () => { } }); + it("shows Remove only after the value has been added", async () => { + renderWithProviders(); + fireEvent.click(screen.getByText("Accessibility")); + const pending = await screen.findByRole("dialog"); + fireEvent.click(within(pending).getByRole("button", { name: "Add Value" })); + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + fireEvent.click(screen.getByText("Accessibility")); + const editing = await screen.findByRole("dialog"); + fireEvent.click( + within(editing).getByRole("button", { name: "More options" }), + ); + expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: "Customize" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: "Duplicate" }), + ).toBeInTheDocument(); + }); + + it("Customize walks name and description before policy details", async () => { + renderWithProviders(); + fireEvent.click(screen.getByText("Accessibility")); + const dialog = await screen.findByRole("dialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: "More options" }), + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); + + expect(await screen.findByPlaceholderText("Policy name")).toHaveValue( + "Accessibility", + ); + expect(screen.queryByText("Custom policy details")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect( + await screen.findByText("Custom policy details"), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "What does this value mean to your group?", + }), + ).toBeInTheDocument(); + }); + + it("Customize shows meaning saved from the value modal", async () => { + renderWithProviders(); + fireEvent.click(screen.getByText("Accessibility")); + const pending = await screen.findByRole("dialog"); + const fields = within(pending).getAllByRole("textbox"); + fireEvent.change(fields[0], { + target: { value: "Edited meaning from value modal" }, + }); + fireEvent.click(within(pending).getByRole("button", { name: "Add Value" })); + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + fireEvent.click(screen.getByText("Accessibility")); + const editing = await screen.findByRole("dialog"); + fireEvent.click( + within(editing).getByRole("button", { name: "More options" }), + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); + expect(await screen.findByPlaceholderText("Policy name")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click( + await screen.findByRole("button", { + name: "What does this value mean to your group?", + }), + ); + expect( + screen.getByDisplayValue("Edited meaning from value modal"), + ).toBeInTheDocument(); + }); + + it("closing Customize without Finalize returns to the value modal", async () => { + renderWithProviders(); + fireEvent.click(screen.getByText("Accessibility")); + const dialog = await screen.findByRole("dialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: "More options" }), + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); + expect( + await screen.findByPlaceholderText("Policy name"), + ).toBeInTheDocument(); + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => { + expect( + screen.queryByPlaceholderText("Policy name"), + ).not.toBeInTheDocument(); + }); + const valueDialog = screen.getByRole("dialog"); + expect( + within(valueDialog).getByRole("button", { name: "Add Value" }), + ).toBeInTheDocument(); + }); + + it("Customize Finalize shows reordered fields in the value modal", async () => { + renderWithProviders(); + fireEvent.click(screen.getByText("Accessibility")); + const dialog = await screen.findByRole("dialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: "More options" }), + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); + + fireEvent.click(await screen.findByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect( + await screen.findByText("Custom policy details"), + ).toBeInTheDocument(); + + const handles = screen.getAllByRole("button", { + name: "Drag to reorder this field", + }); + const rows = screen.getAllByRole("listitem"); + const store: Record = {}; + const dataTransfer = { + effectAllowed: "all", + dropEffect: "move", + setData(type: string, value: string) { + store[type] = value; + }, + getData(type: string) { + return store[type] ?? ""; + }, + }; + fireEvent.pointerDown(handles[0]); + fireEvent.dragStart(rows[0], { dataTransfer }); + fireEvent.drop(rows[1], { dataTransfer }); + + fireEvent.click(screen.getByRole("button", { name: "Finalize" })); + + await waitFor(() => { + expect( + screen.queryByPlaceholderText("Policy name"), + ).not.toBeInTheDocument(); + }); + + const result = screen.getByRole("dialog"); + const labels = within(result) + .getAllByRole("textbox") + .map((el) => { + const labelledby = el.getAttribute("aria-labelledby"); + return labelledby + ? (document.getElementById(labelledby)?.textContent ?? "").trim() + : ""; + }); + expect(labels[0]).toMatch(/Signals of Violation/); + expect(labels[1]).toMatch(/What does this value mean to your group/); + }); + // The "Add value" → custom-chip → modal flow uses a `customPending` // session: dismissing the modal must drop the brand-new chip entirely // (not just unselect it), because the user never confirmed it via @@ -118,7 +296,6 @@ describe("CoreValuesSelectScreen", () => { expect(countCustomChips(CUSTOM_LABEL)).toBe(1); fireEvent.keyDown(document, { key: "Escape" }); - fireEvent.click(await screen.findByRole("button", { name: "Discard" })); await waitFor(() => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); diff --git a/tests/components/CustomMethodCardWizardBlocksList.test.tsx b/tests/components/CustomMethodCardWizardBlocksList.test.tsx new file mode 100644 index 0000000..0eb44d0 --- /dev/null +++ b/tests/components/CustomMethodCardWizardBlocksList.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import { + fireEvent, + renderWithProviders as render, + screen, +} from "../utils/test-utils"; +import { CustomMethodCardWizardBlocksList } from "../../app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizardBlocksList.container"; +import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks"; + +function createDataTransfer() { + const store: Record = {}; + return { + effectAllowed: "all", + dropEffect: "move", + setData(type: string, value: string) { + store[type] = value; + }, + getData(type: string) { + return store[type] ?? ""; + }, + }; +} + +const fieldTypeLabels = { + text: "Text", + badges: "Badges", + upload: "Upload", + proportion: "Proportion", +}; + +const blocks: CustomMethodCardFieldBlock[] = [ + { + kind: "text", + id: "facet-meaning", + blockTitle: "Meaning", + placeholderText: "a", + }, + { + kind: "text", + id: "facet-signals", + blockTitle: "Signals", + placeholderText: "b", + }, +]; + +describe("CustomMethodCardWizardBlocksList", () => { + it("reorders when a row is dropped after dragging from the handle", () => { + const onBlocksReorder = vi.fn(); + render( + {}} + />, + ); + + const handle = screen.getAllByRole("button", { + name: "Drag to reorder this field", + })[0]; + const rows = screen.getAllByRole("listitem"); + const dataTransfer = createDataTransfer(); + + fireEvent.pointerDown(handle); + fireEvent.dragStart(rows[0], { dataTransfer }); + fireEvent.dragOver(rows[1], { dataTransfer }); + fireEvent.drop(rows[1], { dataTransfer }); + + expect(onBlocksReorder).toHaveBeenCalledWith([blocks[1], blocks[0]]); + }); + + it("edits a block when its title is clicked", () => { + const onEditBlock = vi.fn(); + render( + {}} + onEditBlock={onEditBlock} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Meaning" })); + expect(onEditBlock).toHaveBeenCalledWith(blocks[0]); + }); + + it("does not reorder when the drag did not start from the handle", () => { + const onBlocksReorder = vi.fn(); + render( + {}} + />, + ); + + const rows = screen.getAllByRole("listitem"); + const dataTransfer = createDataTransfer(); + + fireEvent.dragStart(rows[0], { dataTransfer }); + fireEvent.drop(rows[1], { dataTransfer }); + + expect(onBlocksReorder).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx index 69a4a37..af464fc 100644 --- a/tests/components/FinalReviewPage.test.tsx +++ b/tests/components/FinalReviewPage.test.tsx @@ -191,27 +191,7 @@ 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 () => { + it("opens the chip modal when a chip is clicked, matching the preset copy", async () => { render(); const signalChip = await screen.findByRole("button", { name: "Signal" }); @@ -274,34 +254,57 @@ describe("FinalReviewScreen — chip detail modal", () => { ); await waitFor(() => { expect( - screen.getByRole("menuitem", { name: /^customize$/i }), + screen.getByRole("menuitem", { name: /^duplicate$/i }), ).toBeInTheDocument(); }); expect( - screen.getByRole("menuitem", { name: /^duplicate$/i }), + screen.getByRole("menuitem", { name: /^customize$/i }), ).toBeInTheDocument(); }); - it("opens method chip modal read-only until Customize, then enables Save after an edit", async () => { + it("values Customize starts on the policy name step", 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 }), + ); + fireEvent.click( + await screen.findByRole("menuitem", { name: /^customize$/i }), + ); + expect( + await screen.findByPlaceholderText("Policy name"), + ).toHaveValue("Accessibility"); + expect(screen.queryByText("Custom policy details")).not.toBeInTheDocument(); + }); + + it("opens method chip modal editable, with Save disabled until a field changes", 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 saveButton = within(dialog).getByRole("button", { name: "Save" }); + expect(saveButton).toBeDisabled(); const principleField = within(dialog).getByRole("textbox", { name: /core principle/i, }); - expect(principleField).toBeDisabled(); - - await enterMethodCustomizeFromDialog(dialog); - - expect( - within(dialog).getByRole("button", { name: "Save" }), - ).toBeDisabled(); + expect(principleField).not.toBeDisabled(); fireEvent.change(principleField, { target: { value: "Edited principle" } }); await waitFor(() => { @@ -339,8 +342,8 @@ describe("FinalReviewScreen — chip detail modal", () => { ).toBeInTheDocument(); }); - it("opens the editable Save modal for a values chip after Customize", async () => { - // Customize / plain custom-rule path: snapshot is set, sections is not. + it("opens the editable Save modal for a values chip", async () => { + // Plain custom-rule path: snapshot is set, sections is not. function CoreValuesHarness() { const { replaceState } = useCreateFlow(); useLayoutEffect(() => { @@ -359,10 +362,6 @@ 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(); @@ -371,7 +370,7 @@ describe("FinalReviewScreen — chip detail modal", () => { ).not.toBeInTheDocument(); }); - it("opens Save for values chip after Customize (use-without-changes seeded snapshot)", async () => { + it("opens Save for a values chip (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`. @@ -401,10 +400,6 @@ 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(); @@ -418,21 +413,10 @@ describe("FinalReviewScreen — chip detail modal", () => { * * 1. Save starts disabled (no edits yet → nothing to persist). * 2. Editing any field flips Save on; clicking it writes the typed - * `{group}MethodDetailsById[id]` entry into create-flow state and - * closes the modal. + * `{group}MethodDetailsById[id]` entry into create-flow state. * 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"], @@ -452,7 +436,6 @@ 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", { @@ -483,7 +466,6 @@ 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 principleField = within(dialog).getByRole("textbox", { name: /core principle/i, }); @@ -492,13 +474,6 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { }); fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); - await waitFor(() => { - expect( - within(screen.getByRole("dialog")).queryByRole("button", { - name: "Save", - }), - ).not.toBeInTheDocument(); - }); await waitFor(() => { expect( latest.communicationMethodDetailsById?.signal?.corePrinciple, @@ -519,7 +494,6 @@ 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 principleField = within(dialog).getByRole("textbox", { name: /core principle/i, }); @@ -626,7 +600,6 @@ 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(/no custom fields yet/i), ).not.toBeInTheDocument(); @@ -670,7 +643,6 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { await screen.findByRole("button", { name: "Custom Comm" }), ); const dialog = await screen.findByRole("dialog"); - 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" })); @@ -685,6 +657,149 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { }); expect(screen.getByRole("dialog")).toBeInTheDocument(); }); + + it("Customize opens the policy wizard prefilled with the chip title", async () => { + render( + {}} + initial={baseSelections} + />, + ); + + fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: /more options/i }), + ); + fireEvent.click( + await screen.findByRole("menuitem", { name: /^customize$/i }), + ); + + expect( + await screen.findByPlaceholderText("Policy name"), + ).toHaveValue("Signal"); + fireEvent.keyDown(document, { key: "Escape" }); + + await waitFor(() => { + expect(screen.queryByPlaceholderText("Policy name")).not.toBeInTheDocument(); + }); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect( + within(screen.getByRole("dialog")).getByText("Signal"), + ).toBeInTheDocument(); + }); + + it("Customize wizard Finalize persists a renamed chip title", async () => { + let latest: CreateFlowState = {}; + render( + { + latest = s; + }} + initial={baseSelections} + />, + ); + + fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: /more options/i }), + ); + fireEvent.click( + await screen.findByRole("menuitem", { name: /^customize$/i }), + ); + + expect( + await screen.findByPlaceholderText("Policy name"), + ).toBeInTheDocument(); + const nameInput = screen.getByPlaceholderText("Policy name"); + fireEvent.change(nameInput, { target: { value: "Custom Signal header" } }); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Finalize" })); + + await waitFor(() => { + expect(latest.customMethodCardMetaById?.signal?.label).toBe( + "Custom Signal header", + ); + }); + }); + + it("Customize Finalize shows reordered field blocks in the chip modal", async () => { + let latest: CreateFlowState = {}; + render( + { + latest = s; + }} + initial={baseSelections} + />, + ); + + fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: /more options/i }), + ); + fireEvent.click( + await screen.findByRole("menuitem", { name: /^customize$/i }), + ); + + fireEvent.click(await screen.findByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect( + await screen.findByText("Custom policy details"), + ).toBeInTheDocument(); + + const handles = screen.getAllByRole("button", { + name: "Drag to reorder this field", + }); + const rows = screen.getAllByRole("listitem"); + const store: Record = {}; + const dataTransfer = { + effectAllowed: "all", + dropEffect: "move", + setData(type: string, value: string) { + store[type] = value; + }, + getData(type: string) { + return store[type] ?? ""; + }, + }; + fireEvent.pointerDown(handles[0]); + fireEvent.dragStart(rows[0], { dataTransfer }); + fireEvent.drop(rows[2], { dataTransfer }); + + fireEvent.click(screen.getByRole("button", { name: "Finalize" })); + + await waitFor(() => { + expect( + screen.queryByPlaceholderText("Policy name"), + ).not.toBeInTheDocument(); + }); + await waitFor(() => { + expect( + latest.customMethodCardFieldBlocksById?.signal?.map((b) => b.id), + ).toEqual([ + "facet-logisticsAdmin", + "facet-codeOfConduct", + "facet-corePrinciple", + ]); + }); + + const result = screen.getByRole("dialog"); + const labels = within(result) + .getAllByRole("textbox") + .map((el) => { + const labelledby = el.getAttribute("aria-labelledby"); + return labelledby + ? (document.getElementById(labelledby)?.textContent ?? "").trim() + : ""; + }); + expect(labels[0]).toMatch(/Logistics, Admin/); + expect(labels[1]).toMatch(/Code of Conduct/); + expect(labels[2]).toMatch(/Core Principle/); + }); }); function FinalReviewEditPublishedWithStateProbe({ diff --git a/tests/pages/communication-methods.test.jsx b/tests/pages/communication-methods.test.jsx index 4e821e7..0145064 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 no modal primary; Remove is in the kebab", async () => { + test("re-opening a selected method shows Save; Remove is in the kebab", async () => { const user = userEvent.setup(); render(); @@ -59,6 +59,9 @@ describe("Create flow communication-methods page", () => { expect( within(dialogAgain).queryByRole("button", { name: "Add Platform" }), ).not.toBeInTheDocument(); + expect( + within(dialogAgain).getByRole("button", { name: "Save" }), + ).toBeInTheDocument(); await user.click(within(dialogAgain).getByRole("button", { name: "More options" })); expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument(); @@ -106,7 +109,7 @@ describe("Create flow communication-methods page", () => { ).not.toBeInTheDocument(); }); - test("unselected preset method fields are disabled until Customize", async () => { + test("unselected preset method fields are editable without Customize", async () => { const user = userEvent.setup(); render(); @@ -117,13 +120,10 @@ describe("Create flow communication-methods page", () => { const dialog = screen.getByRole("dialog"); const textbox = within(dialog).getAllByRole("textbox")[0]; - expect(textbox).toBeDisabled(); + expect(textbox).not.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(); + expect(screen.getByRole("menuitem", { name: "Customize" })).toBeInTheDocument(); }); test("renders without error", () => { @@ -196,7 +196,7 @@ describe("Create flow communication-methods page", () => { ).toBeInTheDocument(); }); - test("opening Create modal for custom policy shows saved field blocks read-only until Customize", async () => { + test("opening Create modal for custom policy shows saved field blocks editable", async () => { const user = userEvent.setup(); const initial = { selectedCommunicationMethodIds: [CUSTOM_POLICY_ID], @@ -227,20 +227,11 @@ describe("Create flow communication-methods page", () => { const textboxesBefore = within(dialog).getAllByRole("textbox"); expect(textboxesBefore).toHaveLength(1); const textarea = textboxesBefore[0]; - expect(textarea).toBeDisabled(); + expect(textarea).not.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 read-only until Customize", async () => { + test("opening Create modal for custom policy shows badge options as interactive chips", async () => { const user = userEvent.setup(); const initial = { selectedCommunicationMethodIds: [CUSTOM_POLICY_ID], @@ -268,22 +259,14 @@ 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: /^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", { + const alpha = within(dialog).getByRole("button", { name: /Deselect Alpha/, }); - const betaAfter = within(screen.getByRole("dialog")).getByRole("button", { + const beta = within(dialog).getByRole("button", { name: /Deselect Beta/, }); - expect(alphaAfter).not.toBeDisabled(); - expect(betaAfter).not.toBeDisabled(); + expect(alpha).not.toBeDisabled(); + expect(beta).not.toBeDisabled(); }); test("editing custom policy field blocks updates draft state after Save", async () => { @@ -322,10 +305,8 @@ describe("Create flow communication-methods page", () => { }); 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: "Customize" })); - const textarea = within(dialog).getAllByRole("textbox")[2]; + const textarea = within(dialog).getByRole("textbox"); await user.clear(textarea); await user.type(textarea, "Updated norms"); diff --git a/tests/pages/decision-approaches.test.jsx b/tests/pages/decision-approaches.test.jsx index 5a12158..7906b35 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 no modal primary; Remove is in the kebab", async () => { + test("re-opening a selected approach shows Save; Remove is in the kebab", async () => { const user = userEvent.setup(); render(); @@ -204,6 +204,9 @@ describe("Create flow decision-approaches page", () => { expect( within(dialogAgain).queryByRole("button", { name: "Add Approach" }), ).not.toBeInTheDocument(); + expect( + within(dialogAgain).getByRole("button", { name: "Save" }), + ).toBeInTheDocument(); await user.click(within(dialogAgain).getByRole("button", { name: "More options" })); expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument(); diff --git a/tests/unit/applyFinalReviewChipEditPatch.test.ts b/tests/unit/applyFinalReviewChipEditPatch.test.ts index 775075f..31a1bea 100644 --- a/tests/unit/applyFinalReviewChipEditPatch.test.ts +++ b/tests/unit/applyFinalReviewChipEditPatch.test.ts @@ -214,4 +214,39 @@ describe("applyFinalReviewChipEditPatch", () => { "1": { meaning: "m2", signals: "s2" }, }); }); + + it("merges customMethodCardFieldBlocksById for a core values patch", () => { + const state: CreateFlowState = { + customMethodCardFieldBlocksById: { + other: [ + { + kind: "text", + id: "x", + blockTitle: "T", + placeholderText: "keep", + }, + ], + }, + }; + const patch: FinalReviewChipEditPatch = { + groupKey: "coreValues", + overrideKey: "1", + value: { meaning: "m", signals: "s" }, + customMethodCardFieldBlocks: [ + { + kind: "text", + id: "facet-meaning", + blockTitle: "Meaning", + placeholderText: "m", + }, + ], + }; + + const result = applyFinalReviewChipEditPatch(state, patch); + + expect(result.customMethodCardFieldBlocksById).toEqual({ + other: state.customMethodCardFieldBlocksById?.other, + "1": patch.customMethodCardFieldBlocks, + }); + }); }); diff --git a/tests/unit/methodCardWizardPrefill.test.ts b/tests/unit/methodCardWizardPrefill.test.ts new file mode 100644 index 0000000..e75a32d --- /dev/null +++ b/tests/unit/methodCardWizardPrefill.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from "vitest"; +import { + buildMethodCardWizardInitialValues, + coreValueDetailsFromWizardFieldBlocks, + facetDetailsToWizardFieldBlocks, +} from "../../lib/create/methodCardWizardPrefill"; +import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks"; + +const cardId = "signal"; +const blocks: CustomMethodCardFieldBlock[] = [ + { kind: "text", id: "b1", blockTitle: "Notes", placeholderText: "…" }, +]; + +const commHeadings = { + corePrinciple: "Core Principle & Scope", + logisticsAdmin: "Logistics, Admin & Norms", + codeOfConduct: "Code of Conduct", +}; + +describe("buildMethodCardWizardInitialValues", () => { + it("uses fallback title and description when meta is missing", () => { + expect( + buildMethodCardWizardInitialValues({ + cardId, + fallbackTitle: "Signal", + fallbackDescription: "Encrypted messaging.", + meta: {}, + persistedBlocks: {}, + draftFieldBlocks: null, + }), + ).toEqual({ + title: "Signal", + description: "Encrypted messaging.", + fieldBlocks: [], + }); + }); + + it("maps facet body fields onto wizard blocks when none exist yet", () => { + expect( + buildMethodCardWizardInitialValues({ + cardId, + fallbackTitle: "Signal", + fallbackDescription: "Encrypted messaging.", + meta: {}, + persistedBlocks: {}, + draftFieldBlocks: null, + facetPrefill: { + group: "communication", + draft: { + corePrinciple: "Privacy first.", + logisticsAdmin: "Admins steward access.", + codeOfConduct: "No leaks.", + }, + headings: commHeadings, + }, + }).fieldBlocks, + ).toEqual([ + { + kind: "text", + id: "facet-corePrinciple", + blockTitle: "Core Principle & Scope", + placeholderText: "Privacy first.", + }, + { + kind: "text", + id: "facet-logisticsAdmin", + blockTitle: "Logistics, Admin & Norms", + placeholderText: "Admins steward access.", + }, + { + kind: "text", + id: "facet-codeOfConduct", + blockTitle: "Code of Conduct", + placeholderText: "No leaks.", + }, + ]); + }); + + it("prefers persisted meta title and description over fallbacks", () => { + expect( + buildMethodCardWizardInitialValues({ + cardId, + fallbackTitle: "Signal", + fallbackDescription: "Encrypted messaging.", + meta: { + [cardId]: { label: "Our Signal", supportText: "Private ops." }, + }, + persistedBlocks: { [cardId]: blocks }, + draftFieldBlocks: null, + }), + ).toEqual({ + title: "Our Signal", + description: "Private ops.", + fieldBlocks: blocks, + }); + }); + + it("overlays current facet field values onto matching persisted blocks", () => { + const persisted: CustomMethodCardFieldBlock[] = [ + { + kind: "text", + id: "facet-corePrinciple", + blockTitle: "Core Principle & Scope", + placeholderText: "Stale principle", + }, + { + kind: "text", + id: "extra-notes", + blockTitle: "Notes", + placeholderText: "keep", + }, + ]; + expect( + buildMethodCardWizardInitialValues({ + cardId, + fallbackTitle: "Signal", + fallbackDescription: "Encrypted messaging.", + meta: {}, + persistedBlocks: { [cardId]: persisted }, + draftFieldBlocks: null, + facetPrefill: { + group: "communication", + draft: { + corePrinciple: "Updated principle", + logisticsAdmin: "Updated logistics", + codeOfConduct: "Updated coc", + }, + headings: commHeadings, + }, + }).fieldBlocks, + ).toEqual([ + { + kind: "text", + id: "facet-corePrinciple", + blockTitle: "Core Principle & Scope", + placeholderText: "Updated principle", + }, + { + kind: "text", + id: "extra-notes", + blockTitle: "Notes", + placeholderText: "keep", + }, + { + kind: "text", + id: "facet-logisticsAdmin", + blockTitle: "Logistics, Admin & Norms", + placeholderText: "Updated logistics", + }, + { + kind: "text", + id: "facet-codeOfConduct", + blockTitle: "Code of Conduct", + placeholderText: "Updated coc", + }, + ]); + }); + + it("prefers in-modal field-block drafts over persisted blocks", () => { + const draft: CustomMethodCardFieldBlock[] = [ + { kind: "proportion", id: "p1", blockTitle: "Share", defaultPercent: 40 }, + ]; + expect( + buildMethodCardWizardInitialValues({ + cardId, + fallbackTitle: "Signal", + fallbackDescription: "Encrypted messaging.", + meta: { + [cardId]: { label: "Our Signal", supportText: "Private ops." }, + }, + persistedBlocks: { [cardId]: blocks }, + draftFieldBlocks: draft, + }).fieldBlocks, + ).toEqual(draft); + }); +}); + +describe("facetDetailsToWizardFieldBlocks", () => { + it("returns no blocks when facet copy is empty", () => { + expect( + facetDetailsToWizardFieldBlocks({ + group: "communication", + draft: { + corePrinciple: " ", + logisticsAdmin: "", + codeOfConduct: "", + }, + headings: commHeadings, + }), + ).toEqual([]); + }); + + it("maps decision scope chips to a badge block", () => { + const blocksOut = facetDetailsToWizardFieldBlocks({ + group: "decisionApproaches", + draft: { + corePrinciple: "Momentum.", + applicableScope: ["Daily Operations"], + selectedApplicableScope: [], + stepByStepInstructions: "Post a deadline.", + consensusLevel: 100, + objectionsDeadlocks: "Any member can block.", + }, + headings: { + corePrinciple: "Core Principle", + applicableScope: "Applicable Scope", + stepByStepInstructions: "Step-by-Step Instructions", + consensusLevel: "Consensus Level", + objectionsDeadlocks: "Objections & Deadlocks", + }, + }); + expect(blocksOut.find((b) => b.kind === "badges")).toMatchObject({ + kind: "badges", + blockTitle: "Applicable Scope", + options: ["Daily Operations"], + }); + expect(blocksOut.find((b) => b.kind === "proportion")).toMatchObject({ + defaultPercent: 100, + }); + }); + + it("maps core value meaning and signals onto text blocks", () => { + expect( + facetDetailsToWizardFieldBlocks({ + group: "coreValues", + draft: { + meaning: "Everyone can join.", + signals: "Inaccessible venues.", + }, + headings: { + meaning: "What does this value mean to your group?", + signals: "Signals of Violation", + }, + }), + ).toEqual([ + { + kind: "text", + id: "facet-meaning", + blockTitle: "What does this value mean to your group?", + placeholderText: "Everyone can join.", + }, + { + kind: "text", + id: "facet-signals", + blockTitle: "Signals of Violation", + placeholderText: "Inaccessible venues.", + }, + ]); + }); +}); + +describe("coreValueDetailsFromWizardFieldBlocks", () => { + it("reads meaning and signals from facet block ids", () => { + expect( + coreValueDetailsFromWizardFieldBlocks( + [ + { + kind: "text", + id: "facet-meaning", + blockTitle: "Meaning", + placeholderText: "Updated meaning", + }, + { + kind: "text", + id: "facet-signals", + blockTitle: "Signals", + placeholderText: "Updated signals", + }, + ], + { meaning: "old m", signals: "old s", supportText: "keep" }, + ), + ).toEqual({ + meaning: "Updated meaning", + signals: "Updated signals", + supportText: "keep", + }); + }); +}); diff --git a/tests/unit/usesWizardFieldBlocksModalBody.test.ts b/tests/unit/usesWizardFieldBlocksModalBody.test.ts index a2c5028..74c8f3f 100644 --- a/tests/unit/usesWizardFieldBlocksModalBody.test.ts +++ b/tests/unit/usesWizardFieldBlocksModalBody.test.ts @@ -113,4 +113,16 @@ describe("usesWizardFieldBlocksModalBody", () => { }), ).toBe(true); }); + + it("is true for catalog ids when the wizard draft has blocks", () => { + expect( + usesWizardFieldBlocksModalBody({ + methodId: "signal", + meta: {}, + fieldBlocksById: {}, + modalEditUnlocked: false, + draftFieldBlocks: blocks, + }), + ).toBe(true); + }); });