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 <cursoragent@cursor.com>
This commit is contained in:
adilallo
2026-08-21 13:51:37 -06:00
co-authored by Cursor
parent e9951f260b
commit 4006fe5c6c
31 changed files with 3062 additions and 1530 deletions
@@ -5,8 +5,12 @@ import {
useMessages, useMessages,
useTranslation, useTranslation,
} from "../../../../contexts/MessagesContext"; } from "../../../../contexts/MessagesContext";
import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; 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 { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types"; import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
import { CustomMethodCardWizardView } from "./CustomMethodCardWizard.view"; import { CustomMethodCardWizardView } from "./CustomMethodCardWizard.view";
@@ -17,12 +21,13 @@ import type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types
* `20066:14748`, `20094:48551`, `20066:14361`). * `20066:14748`, `20094:48551`, `20066:14361`).
*/ */
const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>( const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
({ isOpen, onClose, onFinalize, onPersistCustomUploadFile }) => { ({ isOpen, onClose, onFinalize, onPersistCustomUploadFile, initialValues }) => {
const m = useMessages(); const m = useMessages();
const t = useTranslation("common"); const t = useTranslation("common");
const tUpload = useTranslation("create.upload"); const tUpload = useTranslation("create.upload");
const w = m.create.customRule.customMethodCardWizard; const w = m.create.customRule.customMethodCardWizard;
const menuCopy = m.create.customRule.modalKebabMenu; const menuCopy = m.create.customRule.modalKebabMenu;
const { requestConfirm, confirmDialog } = useAsyncConfirm();
const copy = useMemo( const copy = useMemo(
() => ({ () => ({
@@ -68,6 +73,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
const [draftFieldBlocks, setDraftFieldBlocks] = useState< const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] CustomMethodCardFieldBlock[]
>([]); >([]);
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
const [textBlockTitle, setTextBlockTitle] = useState(""); const [textBlockTitle, setTextBlockTitle] = useState("");
const [textPlaceholderBody, setTextPlaceholderBody] = useState(""); const [textPlaceholderBody, setTextPlaceholderBody] = useState("");
@@ -88,6 +94,14 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
const [proportionDefault, setProportionDefault] = useState(50); const [proportionDefault, setProportionDefault] = useState(50);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const initialValuesRef = useRef(initialValues);
initialValuesRef.current = initialValues;
const openSnapshotRef = useRef<{
title: string;
description: string;
fieldBlocks: string;
} | null>(null);
const fieldModalSnapshotRef = useRef<string | null>(null);
const resetFieldTypeDrafts = useCallback(() => { const resetFieldTypeDrafts = useCallback(() => {
setTextBlockTitle(""); setTextBlockTitle("");
@@ -112,21 +126,112 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
setPolicyDescription(""); setPolicyDescription("");
setAddFieldExpanded(false); setAddFieldExpanded(false);
setFieldTypeModal(null); setFieldTypeModal(null);
setEditingBlockId(null);
setDraftFieldBlocks([]); setDraftFieldBlocks([]);
openSnapshotRef.current = null;
fieldModalSnapshotRef.current = null;
resetFieldTypeDrafts(); resetFieldTypeDrafts();
}, [resetFieldTypeDrafts]); }, [resetFieldTypeDrafts]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
reset(); 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(() => { const dismiss = useCallback(() => {
reset(); reset();
onClose(); onClose();
}, [onClose, reset]); }, [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 titleTrim = policyTitle.trim();
const descriptionTrim = policyDescription.trim(); const descriptionTrim = policyDescription.trim();
@@ -136,7 +241,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
titleTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS; titleTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
const descriptionOk = const descriptionOk =
descriptionTrim.length > 0 && 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 === 1) return titleOk;
if (wizardStep === 2) return descriptionOk; if (wizardStep === 2) return descriptionOk;
return titleOk && descriptionOk; return titleOk && descriptionOk;
@@ -213,7 +318,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
const shellDescription = fieldModalHeader?.description ?? headerDescription; const shellDescription = fieldModalHeader?.description ?? headerDescription;
const nextLabel = fieldTypeModal const nextLabel = fieldTypeModal
? copy.fieldModals.addField ? editingBlockId
? copy.fieldModals.saveField
: copy.fieldModals.addField
: wizardStep === 3 : wizardStep === 3
? copy.footerFinalize ? copy.footerFinalize
: t("buttons.next"); : t("buttons.next");
@@ -222,33 +329,124 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
? !fieldModalStepValid ? !fieldModalStepValid
: !stepValid; : !stepValid;
const handleShellClose = useCallback(() => { const handleShellClose = useCallback(async () => {
if (fieldTypeModal) { if (fieldTypeModal) {
if (
fieldModalSnapshotRef.current != null &&
fieldModalDraftSignature() !== fieldModalSnapshotRef.current &&
!(await confirmAbandonWizardEdits())
) {
return;
}
setFieldTypeModal(null); setFieldTypeModal(null);
setEditingBlockId(null);
fieldModalSnapshotRef.current = null;
return;
}
if (!(await confirmAbandonWizardEdits())) {
return; return;
} }
dismiss(); dismiss();
}, [dismiss, fieldTypeModal]); }, [
confirmAbandonWizardEdits,
dismiss,
fieldModalDraftSignature,
fieldTypeModal,
]);
const kebabMenuItems = useMemo<ModalHeaderMenuItem[]>(() => [], []); const kebabMenuItems = useMemo<ModalHeaderMenuItem[]>(() => [], []);
const handleBack = useCallback(() => { const handleBack = useCallback(async () => {
if (fieldTypeModal) { if (fieldTypeModal) {
if (
fieldModalSnapshotRef.current != null &&
fieldModalDraftSignature() !== fieldModalSnapshotRef.current &&
!(await confirmAbandonWizardEdits())
) {
return;
}
setFieldTypeModal(null); setFieldTypeModal(null);
setEditingBlockId(null);
fieldModalSnapshotRef.current = null;
return; return;
} }
if (wizardStep === 1) { if (wizardStep === 1) {
if (!(await confirmAbandonWizardEdits())) {
return;
}
dismiss(); dismiss();
return; return;
} }
setWizardStep((s) => (s === 2 ? 1 : 2)); setWizardStep((s) => (s === 2 ? 1 : 2));
}, [dismiss, fieldTypeModal, wizardStep]); }, [
confirmAbandonWizardEdits,
dismiss,
fieldModalDraftSignature,
fieldTypeModal,
wizardStep,
]);
const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => { const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => {
setEditingBlockId(null);
resetFieldTypeDrafts(); resetFieldTypeDrafts();
setFieldTypeModal(ft); setFieldTypeModal(ft);
fieldModalSnapshotRef.current = JSON.stringify({
fieldTypeModal: ft,
textBlockTitle: "",
textPlaceholderBody: "",
badgeBlockTitle: "",
badgeOptions: [],
uploadBlockTitle: "",
uploadFileName: undefined,
uploadAssetUrl: undefined,
proportionBlockTitle: "",
proportionDefault: 50,
});
}, [resetFieldTypeDrafts]); }, [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( const handleFileChosen = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => { async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -285,9 +483,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
); );
}, []); }, []);
const appendFieldBlock = useCallback(() => { const commitFieldBlock = useCallback(() => {
if (!fieldTypeModal || !fieldModalStepValid) return; if (!fieldTypeModal || !fieldModalStepValid) return;
const id = crypto.randomUUID(); const id = editingBlockId ?? crypto.randomUUID();
let block: CustomMethodCardFieldBlock; let block: CustomMethodCardFieldBlock;
switch (fieldTypeModal) { switch (fieldTypeModal) {
case "text": case "text":
@@ -325,11 +523,19 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
defaultPercent: proportionDefault, defaultPercent: proportionDefault,
}; };
} }
setDraftFieldBlocks((prev) => [...prev, block]); setDraftFieldBlocks((prev) => {
if (editingBlockId) {
return prev.map((row) => (row.id === editingBlockId ? block : row));
}
return [...prev, block];
});
setFieldTypeModal(null); setFieldTypeModal(null);
setEditingBlockId(null);
fieldModalSnapshotRef.current = null;
}, [ }, [
badgeBlockTitle, badgeBlockTitle,
badgeOptions, badgeOptions,
editingBlockId,
fieldModalStepValid, fieldModalStepValid,
fieldTypeModal, fieldTypeModal,
proportionBlockTitle, proportionBlockTitle,
@@ -343,7 +549,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
const handleNext = useCallback(() => { const handleNext = useCallback(() => {
if (fieldTypeModal) { if (fieldTypeModal) {
appendFieldBlock(); commitFieldBlock();
return; return;
} }
if (!stepValid) return; if (!stepValid) return;
@@ -358,7 +564,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
} }
setWizardStep((s) => (s === 1 ? 2 : 3)); setWizardStep((s) => (s === 1 ? 2 : 3));
}, [ }, [
appendFieldBlock, commitFieldBlock,
descriptionTrim, descriptionTrim,
dismiss, dismiss,
draftFieldBlocks, draftFieldBlocks,
@@ -370,6 +576,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
]); ]);
return ( return (
<>
<CustomMethodCardWizardView <CustomMethodCardWizardView
isOpen={isOpen} isOpen={isOpen}
onDismiss={handleShellClose} onDismiss={handleShellClose}
@@ -380,7 +587,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
policyDescription={policyDescription} policyDescription={policyDescription}
addFieldExpanded={addFieldExpanded} addFieldExpanded={addFieldExpanded}
copy={copy} 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} onPolicyTitleChange={setPolicyTitle}
onPolicyDescriptionChange={setPolicyDescription} onPolicyDescriptionChange={setPolicyDescription}
onPressAddCustomField={() => setAddFieldExpanded(true)} onPressAddCustomField={() => setAddFieldExpanded(true)}
@@ -420,10 +628,13 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
stepper={!fieldTypeModal} stepper={!fieldTypeModal}
draftFieldBlocks={draftFieldBlocks} draftFieldBlocks={draftFieldBlocks}
onDraftFieldBlocksReorder={setDraftFieldBlocks} onDraftFieldBlocksReorder={setDraftFieldBlocks}
onEditFieldBlock={handleEditFieldBlock}
kebabMoreOptionsAriaLabel={menuCopy.triggerAriaLabel} kebabMoreOptionsAriaLabel={menuCopy.triggerAriaLabel}
kebabMenuAriaLabel={menuCopy.menuAriaLabel} kebabMenuAriaLabel={menuCopy.menuAriaLabel}
kebabMenuItems={kebabMenuItems} kebabMenuItems={kebabMenuItems}
/> />
{confirmDialog}
</>
); );
}, },
); );
@@ -2,6 +2,7 @@ import type { RefObject } from "react";
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types"; import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types"; import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
export interface CustomMethodCardWizardFieldBodiesCopy { export interface CustomMethodCardWizardFieldBodiesCopy {
requiredHint: string; requiredHint: string;
@@ -47,6 +48,7 @@ export interface CustomMethodCardWizardCopy {
footerFinalize: string; footerFinalize: string;
fieldModals: { fieldModals: {
addField: string; addField: string;
saveField: string;
requiredHint: string; requiredHint: string;
text: CustomMethodCardWizardFieldBodiesCopy["text"] & { text: CustomMethodCardWizardFieldBodiesCopy["text"] & {
title: string; title: string;
@@ -70,6 +72,8 @@ export interface CustomMethodCardWizardCopy {
export interface CustomMethodCardWizardProps { export interface CustomMethodCardWizardProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; 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. */ /** Called when the user completes step 3; parent assigns id and persists state. */
onFinalize: (payload: { onFinalize: (payload: {
title: string; title: string;
@@ -123,7 +127,8 @@ export interface CustomMethodCardWizardViewProps {
policyDescription: string; policyDescription: string;
addFieldExpanded: boolean; addFieldExpanded: boolean;
copy: CustomMethodCardWizardCopy; copy: CustomMethodCardWizardCopy;
maxChars: number; maxTitleChars: number;
maxDescriptionChars: number;
onPolicyTitleChange: (v: string) => void; onPolicyTitleChange: (v: string) => void;
onPolicyDescriptionChange: (v: string) => void; onPolicyDescriptionChange: (v: string) => void;
onPressAddCustomField: () => void; onPressAddCustomField: () => void;
@@ -136,6 +141,7 @@ export interface CustomMethodCardWizardViewProps {
>; >;
draftFieldBlocks: CustomMethodCardFieldBlock[]; draftFieldBlocks: CustomMethodCardFieldBlock[];
onDraftFieldBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void; onDraftFieldBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
onEditFieldBlock: (_block: CustomMethodCardFieldBlock) => void;
nextDisabled: boolean; nextDisabled: boolean;
nextLabel: string; nextLabel: string;
showBackButton: boolean; showBackButton: boolean;
@@ -19,7 +19,8 @@ function CustomMethodCardWizardViewComponent({
policyDescription, policyDescription,
addFieldExpanded, addFieldExpanded,
copy, copy,
maxChars, maxTitleChars,
maxDescriptionChars,
onPolicyTitleChange, onPolicyTitleChange,
onPolicyDescriptionChange, onPolicyDescriptionChange,
onPressAddCustomField, onPressAddCustomField,
@@ -35,6 +36,7 @@ function CustomMethodCardWizardViewComponent({
stepper, stepper,
draftFieldBlocks, draftFieldBlocks,
onDraftFieldBlocksReorder, onDraftFieldBlocksReorder,
onEditFieldBlock,
kebabMoreOptionsAriaLabel, kebabMoreOptionsAriaLabel,
kebabMenuAriaLabel, kebabMenuAriaLabel,
kebabMenuItems, kebabMenuItems,
@@ -71,7 +73,7 @@ function CustomMethodCardWizardViewComponent({
placeholder={copy.step1.fieldPlaceholder} placeholder={copy.step1.fieldPlaceholder}
value={policyTitle} value={policyTitle}
onChange={onPolicyTitleChange} onChange={onPolicyTitleChange}
maxLength={maxChars} maxLength={maxTitleChars}
/> />
) : null} ) : null}
{!fieldTypeModal && wizardStep === 2 ? ( {!fieldTypeModal && wizardStep === 2 ? (
@@ -80,9 +82,9 @@ function CustomMethodCardWizardViewComponent({
formHeader={false} formHeader={false}
placeholder={copy.step2.fieldPlaceholder} placeholder={copy.step2.fieldPlaceholder}
value={policyDescription} value={policyDescription}
maxLength={maxChars} maxLength={maxDescriptionChars}
onChange={(e) => onPolicyDescriptionChange(e.target.value)} onChange={(e) => onPolicyDescriptionChange(e.target.value)}
textHint={`${policyDescription.length}/${maxChars}`} textHint={`${policyDescription.length}/${maxDescriptionChars}`}
className="w-full" className="w-full"
rows={4} rows={4}
/> />
@@ -96,6 +98,7 @@ function CustomMethodCardWizardViewComponent({
dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel} dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel}
listLabel={copy.step3BlocksList.listLabel} listLabel={copy.step3BlocksList.listLabel}
onBlocksReorder={onDraftFieldBlocksReorder} onBlocksReorder={onDraftFieldBlocksReorder}
onEditBlock={onEditFieldBlock}
/> />
) : null} ) : null}
<AddCustomField <AddCustomField
@@ -1,6 +1,6 @@
"use client"; "use client";
import { memo, useCallback, useState, type DragEvent } from "react"; import { memo, useCallback, useRef, useState, type DragEvent } from "react";
import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks"; import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks";
import { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view"; import { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view";
import type { CustomMethodCardWizardBlocksListProps } from "./CustomMethodCardWizardBlocksList.types"; import type { CustomMethodCardWizardBlocksListProps } from "./CustomMethodCardWizardBlocksList.types";
@@ -11,19 +11,33 @@ function CustomMethodCardWizardBlocksListContainerComponent({
dragHandleAriaLabel, dragHandleAriaLabel,
listLabel, listLabel,
onBlocksReorder, onBlocksReorder,
onEditBlock,
}: CustomMethodCardWizardBlocksListProps) { }: CustomMethodCardWizardBlocksListProps) {
const [draggingIndex, setDraggingIndex] = useState<number | null>(null); const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [overIndex, setOverIndex] = useState<number | null>(null); const [overIndex, setOverIndex] = useState<number | null>(null);
const draggingIndexRef = useRef<number | null>(null);
const dragFromHandleRef = useRef(false);
const clearDragUi = useCallback(() => { const clearDragUi = useCallback(() => {
draggingIndexRef.current = null;
dragFromHandleRef.current = false;
setDraggingIndex(null); setDraggingIndex(null);
setOverIndex(null); setOverIndex(null);
}, []); }, []);
const handleHandlePointerDown = useCallback(() => {
dragFromHandleRef.current = true;
}, []);
const handleDragStart = useCallback( const handleDragStart = useCallback(
(index: number) => (e: DragEvent) => { (index: number) => (e: DragEvent) => {
if (!dragFromHandleRef.current) {
e.preventDefault();
return;
}
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(index)); e.dataTransfer.setData("text/plain", String(index));
draggingIndexRef.current = index;
setDraggingIndex(index); setDraggingIndex(index);
}, },
[], [],
@@ -40,8 +54,11 @@ function CustomMethodCardWizardBlocksListContainerComponent({
const handleDrop = useCallback( const handleDrop = useCallback(
(index: number) => (e: DragEvent) => { (index: number) => (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
const from = Number.parseInt(e.dataTransfer.getData("text/plain"), 10); const fromData = Number.parseInt(e.dataTransfer.getData("text/plain"), 10);
if (Number.isNaN(from)) { const from = Number.isNaN(fromData)
? draggingIndexRef.current
: fromData;
if (from == null || Number.isNaN(from)) {
clearDragUi(); clearDragUi();
return; return;
} }
@@ -66,6 +83,8 @@ function CustomMethodCardWizardBlocksListContainerComponent({
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDrop} onDrop={handleDrop}
onDragEnd={clearDragUi} onDragEnd={clearDragUi}
onHandlePointerDown={handleHandlePointerDown}
onEditBlock={onEditBlock}
/> />
); );
} }
@@ -8,6 +8,7 @@ export interface CustomMethodCardWizardBlocksListProps {
dragHandleAriaLabel: string; dragHandleAriaLabel: string;
listLabel: string; listLabel: string;
onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void; onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
onEditBlock: (_block: CustomMethodCardFieldBlock) => void;
} }
export interface CustomMethodCardWizardBlocksListViewProps export interface CustomMethodCardWizardBlocksListViewProps
@@ -18,4 +19,5 @@ export interface CustomMethodCardWizardBlocksListViewProps
onDragOver: (_index: number) => (_e: DragEvent) => void; onDragOver: (_index: number) => (_e: DragEvent) => void;
onDrop: (_index: number) => (_e: DragEvent) => void; onDrop: (_index: number) => (_e: DragEvent) => void;
onDragEnd: () => void; onDragEnd: () => void;
onHandlePointerDown: () => void;
} }
@@ -38,6 +38,8 @@ function CustomMethodCardWizardBlocksListViewComponent({
onDragOver, onDragOver,
onDrop, onDrop,
onDragEnd, onDragEnd,
onHandlePointerDown,
onEditBlock,
}: CustomMethodCardWizardBlocksListViewProps) { }: CustomMethodCardWizardBlocksListViewProps) {
return ( return (
<ul <ul
@@ -53,6 +55,7 @@ function CustomMethodCardWizardBlocksListViewComponent({
return ( return (
<li <li
key={block.id} key={block.id}
draggable
aria-grabbed={isDragging ? true : undefined} aria-grabbed={isDragging ? true : undefined}
className={`group flex min-h-[52px] items-stretch gap-2 rounded-[var(--measures-radius-medium,8px)] border-2 bg-[var(--color-surface-default-secondary)] py-2 pl-1 pr-3 transition-[border-color,box-shadow,opacity] ${ className={`group flex min-h-[52px] items-stretch gap-2 rounded-[var(--measures-radius-medium,8px)] border-2 bg-[var(--color-surface-default-secondary)] py-2 pl-1 pr-3 transition-[border-color,box-shadow,opacity] ${
isDropTarget isDropTarget
@@ -61,19 +64,26 @@ function CustomMethodCardWizardBlocksListViewComponent({
? "relative z-20 border-[var(--color-border-default-primary)] opacity-60 shadow-[0_4px_12px_rgba(0,0,0,0.12)]" ? "relative z-20 border-[var(--color-border-default-primary)] opacity-60 shadow-[0_4px_12px_rgba(0,0,0,0.12)]"
: "border-[var(--color-border-default-primary)] hover:border-[var(--color-content-default-secondary)]" : "border-[var(--color-border-default-primary)] hover:border-[var(--color-content-default-secondary)]"
}`} }`}
onDragStart={onDragStart(index)}
onDragOver={onDragOver(index)} onDragOver={onDragOver(index)}
onDrop={onDrop(index)} onDrop={onDrop(index)}
>
<button
type="button"
draggable
onDragStart={onDragStart(index)}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
>
<span
role="button"
tabIndex={0}
aria-label={dragHandleAriaLabel} aria-label={dragHandleAriaLabel}
className="flex shrink-0 cursor-grab touch-manipulation items-center self-stretch rounded-[var(--measures-radius-200,8px)] border-0 bg-transparent px-1 text-[var(--color-content-default-secondary)] transition-colors active:cursor-grabbing focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-invert-primary)] group-hover:text-[var(--color-content-default-primary)]" onPointerDown={onHandlePointerDown}
className="flex shrink-0 cursor-grab touch-manipulation items-center self-stretch rounded-[var(--measures-radius-200,8px)] px-1 text-[var(--color-content-default-secondary)] transition-colors active:cursor-grabbing focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-invert-primary)] group-hover:text-[var(--color-content-default-primary)]"
> >
<DragHandleGlyph /> <DragHandleGlyph />
</button> </span>
<button
type="button"
aria-label={title}
onClick={() => onEditBlock(block)}
className="flex min-w-0 flex-1 items-center gap-2 self-stretch rounded-[var(--measures-radius-200,8px)] border-0 bg-transparent px-0 text-left cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-invert-primary)]"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center self-center"> <span className="flex h-8 w-8 shrink-0 items-center justify-center self-center">
<Icon <Icon
name={ADD_CUSTOM_FIELD_TYPE_ICONS[kind]} name={ADD_CUSTOM_FIELD_TYPE_ICONS[kind]}
@@ -89,6 +99,7 @@ function CustomMethodCardWizardBlocksListViewComponent({
{typeLabel} {typeLabel}
</span> </span>
</div> </div>
</button>
</li> </li>
); );
})} })}
File diff suppressed because it is too large Load Diff
@@ -53,20 +53,20 @@ import type { CommunicationMethodDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
export function CommunicationMethodsScreen() { export function CommunicationMethodsScreen() {
const m = useMessages(); const m = useMessages();
const comm = m.create.customRule.communication; const comm = m.create.customRule.communication;
const modalKebabMenu = m.create.customRule.modalKebabMenu; const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp(); const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } = const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } = const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow(); useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null); const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
@@ -79,12 +79,14 @@ export function CommunicationMethodsScreen() {
const [pendingDraft, setPendingDraft] = const [pendingDraft, setPendingDraft] =
useState<CommunicationMethodDetailEntry | null>(null); useState<CommunicationMethodDetailEntry | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const [modalEditUnlocked, setModalEditUnlocked] = useState(false); const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState<
string | null
>(null);
const [wizardInitialValues, setWizardInitialValues] =
useState<MethodCardWizardInitialValues | null>(null);
const [draftFieldBlocks, setDraftFieldBlocks] = useState< const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
useState<MethodCardHeaderDraft | null>(null);
const selectedIds = state.selectedCommunicationMethodIds ?? []; const selectedIds = state.selectedCommunicationMethodIds ?? [];
@@ -107,6 +109,8 @@ export function CommunicationMethodsScreen() {
const handleOpenAddWizard = useCallback(() => { const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
setAddCustomWizardOpen(true); setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]); }, [markCreateFlowInteraction]);
@@ -144,15 +148,40 @@ export function CommunicationMethodsScreen() {
const handleCardClick = useCallback( const handleCardClick = useCallback(
(id: string) => { (id: string) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
customizeSnapshotRef.current = null; const draft = seedDraft(id);
setModalEditUnlocked(false); const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
setDraftFieldBlocks(null); const initialBlocks =
setCustomizeHeaderDraft(null); 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); setPendingCardId(id);
setPendingDraft(seedDraft(id)); setPendingDraft(draft);
setDraftFieldBlocks(initialBlocks);
setCreateModalOpen(true); setCreateModalOpen(true);
}, },
[markCreateFlowInteraction, seedDraft], [
comm.confirmModal.description,
comm.confirmModal.title,
markCreateFlowInteraction,
methodById,
seedDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
],
); );
const handleDraftChange = useCallback( const handleDraftChange = useCallback(
@@ -165,9 +194,7 @@ export function CommunicationMethodsScreen() {
const isSelectedCardModal = const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId); pendingCardId !== null && selectedIds.includes(pendingCardId);
const fieldsLocked = !modalEditUnlocked; const showMethodModalPrimary = true;
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
const customFacetDetailsMatchPreset = useMemo(() => { const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false; if (!pendingCardId || !pendingDraft) return false;
@@ -189,7 +216,7 @@ export function CommunicationMethodsScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked, modalEditUnlocked: false,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -197,7 +224,6 @@ export function CommunicationMethodsScreen() {
[ [
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingCardId, pendingCardId,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -207,11 +233,11 @@ export function CommunicationMethodsScreen() {
const handleCreateModalClose = useCallback(async () => { const handleCreateModalClose = useCallback(async () => {
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -239,53 +265,14 @@ export function CommunicationMethodsScreen() {
setCreateModalOpen(false); setCreateModalOpen(false);
setPendingCardId(null); setPendingCardId(null);
setPendingDraft(null); setPendingDraft(null);
setModalEditUnlocked(false);
setDraftFieldBlocks(null); setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingDraft, pendingDraft,
replaceState, 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 () => { const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) { if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return; return;
@@ -293,11 +280,11 @@ export function CommunicationMethodsScreen() {
markCreateFlowInteraction(); markCreateFlowInteraction();
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -313,11 +300,9 @@ export function CommunicationMethodsScreen() {
await handleCreateModalClose(); await handleCreateModalClose();
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
pendingDraft, pendingDraft,
pendingCardId, pendingCardId,
selectedIds, selectedIds,
@@ -327,37 +312,36 @@ export function CommunicationMethodsScreen() {
const handleCustomize = useCallback(() => { const handleCustomize = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
if (!pendingDraft || !pendingCardId) { if (!pendingCardId) {
return; return;
} }
const persistedBlocks =
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [];
const initialFieldBlocks =
persistedBlocks.length > 0
? structuredClone(persistedBlocks)
: isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? []
: null;
const method = methodById.get(pendingCardId); const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId]; setWizardInitialValues(
const headerDraft: MethodCardHeaderDraft = { buildMethodCardWizardInitialValues({
title: meta?.label ?? method?.label ?? comm.confirmModal.title, cardId: pendingCardId,
description: fallbackTitle: method?.label ?? comm.confirmModal.title,
meta?.supportText ?? fallbackDescription:
method?.supportText ?? method?.supportText ?? comm.confirmModal.description,
comm.confirmModal.description, meta: state.customMethodCardMetaById,
}; persistedBlocks: state.customMethodCardFieldBlocksById,
setCustomizeHeaderDraft(headerDraft); draftFieldBlocks,
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( facetPrefill: pendingDraft
pendingDraft, ? {
initialFieldBlocks, group: "communication",
headerDraft, draft: pendingDraft,
headings: comm.sectionHeadings,
}
: undefined,
}),
); );
setDraftFieldBlocks(initialFieldBlocks); setWizardCustomizeCardId(pendingCardId);
setModalEditUnlocked(true); setCreateModalOpen(false);
setAddCustomWizardOpen(true);
}, [ }, [
comm.confirmModal.description, comm.confirmModal.description,
comm.confirmModal.title, comm.confirmModal.title,
comm.sectionHeadings,
draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
pendingCardId, pendingCardId,
@@ -382,9 +366,7 @@ export function CommunicationMethodsScreen() {
() => communicationPresetFor(newId), () => communicationPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -414,16 +396,15 @@ export function CommunicationMethodsScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
markCreateFlowInteraction, markCreateFlowInteraction,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
state.communicationMethodDetailsById, state.communicationMethodDetailsById,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -449,9 +430,7 @@ export function CommunicationMethodsScreen() {
() => communicationPresetFor(newId), () => communicationPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -481,14 +460,13 @@ export function CommunicationMethodsScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
draftFieldBlocks, draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
@@ -501,7 +479,7 @@ export function CommunicationMethodsScreen() {
const kebabMenuItems = useMemo( const kebabMenuItems = useMemo(
() => () =>
buildCustomRuleModalKebabMenu(modalKebabMenu, { buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked, showCustomize: true,
onCustomize: handleCustomize, onCustomize: handleCustomize,
onDuplicate: onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
@@ -521,7 +499,6 @@ export function CommunicationMethodsScreen() {
handleDuplicatePrefabCard, handleDuplicatePrefabCard,
handleRemoveSelectedFromModal, handleRemoveSelectedFromModal,
isSelectedCardModal, isSelectedCardModal,
modalEditUnlocked,
modalKebabMenu, modalKebabMenu,
pendingCardId, pendingCardId,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -540,7 +517,7 @@ export function CommunicationMethodsScreen() {
meta?.supportText ?? meta?.supportText ??
method?.supportText ?? method?.supportText ??
comm.confirmModal.description, comm.confirmModal.description,
nextButtonText: modalEditUnlocked nextButtonText: isSelectedCardModal
? saveLabel ? saveLabel
: comm.addPlatform.nextButtonText, : comm.addPlatform.nextButtonText,
}; };
@@ -552,8 +529,14 @@ export function CommunicationMethodsScreen() {
}; };
const handleCloseAddWizard = useCallback(() => { const handleCloseAddWizard = useCallback(() => {
const resumeCardId = wizardCustomizeCardId;
setAddCustomWizardOpen(false); setAddCustomWizardOpen(false);
}, []); setWizardCustomizeCardId(null);
setWizardInitialValues(null);
if (resumeCardId && pendingCardId === resumeCardId) {
setCreateModalOpen(true);
}
}, [pendingCardId, wizardCustomizeCardId]);
const handleFinalizeCustomCard = useCallback( const handleFinalizeCustomCard = useCallback(
({ ({
@@ -566,6 +549,42 @@ export function CommunicationMethodsScreen() {
fieldBlocks: CustomMethodCardFieldBlock[]; fieldBlocks: CustomMethodCardFieldBlock[];
}) => { }) => {
markCreateFlowInteraction(); 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(); const id = crypto.randomUUID();
updateState({ updateState({
selectedCommunicationMethodIds: moveFacetSelectionIdToFront( selectedCommunicationMethodIds: moveFacetSelectionIdToFront(
@@ -588,91 +607,29 @@ export function CommunicationMethodsScreen() {
}, },
[ [
markCreateFlowInteraction, markCreateFlowInteraction,
pendingDraft,
selectedIds, selectedIds,
state.communicationMethodDetailsById, state.communicationMethodDetailsById,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
updateState, updateState,
wizardCustomizeCardId,
], ],
); );
const handleCreateModalPrimary = useCallback(() => { const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) { if (!pendingCardId) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
markCreateFlowInteraction(); markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) { const persistWizardBlocks =
if (modalEditUnlocked) { modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
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;
}
if (modalEditUnlocked) { if (selectedIds.includes(pendingCardId)) {
if (!customizeHeaderDraft) { if (persistWizardBlocks) {
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({ updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: { customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}), ...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []), [pendingCardId]: structuredClone(draftFieldBlocks ?? []),
@@ -680,22 +637,27 @@ export function CommunicationMethodsScreen() {
}); });
} else if (pendingDraft) { } else if (pendingDraft) {
updateState({ updateState({
customMethodCardMetaById: nextMeta,
communicationMethodDetailsById: { communicationMethodDetailsById: {
...(state.communicationMethodDetailsById ?? {}), ...(state.communicationMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: pendingDraft,
}, },
}); });
} }
customizeSnapshotRef.current = null; if (pendingDraft) {
setModalEditUnlocked(false); customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
setDraftFieldBlocks(null); pendingDraft,
setCustomizeHeaderDraft(null); persistWizardBlocks ? draftFieldBlocks : null,
customizeSnapshotRef.current?.headerDraft ?? {
title: "",
description: "",
},
);
}
return; return;
} }
if (!pendingDraft) { if (!pendingDraft) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
updateState({ updateState({
@@ -707,15 +669,23 @@ export function CommunicationMethodsScreen() {
...(state.communicationMethodDetailsById ?? {}), ...(state.communicationMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: pendingDraft,
}, },
...(persistWizardBlocks
? {
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
}
: {}),
}); });
pendingEphemeralDuplicateIdRef.current = null; pendingEphemeralDuplicateIdRef.current = null;
handleCreateModalClose(); customizeSnapshotRef.current = null;
void handleCreateModalClose();
}, [ }, [
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked, modalUsesWizardFieldBlocksBody,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
selectedIds, selectedIds,
@@ -766,33 +736,10 @@ export function CommunicationMethodsScreen() {
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
titleValue={customizeHeaderDraft.title}
descriptionValue={customizeHeaderDraft.description}
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={(description) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, description } : null,
)
}
/>
) : undefined
}
onNext={handleCreateModalPrimary} onNext={handleCreateModalPrimary}
title={modalConfig.title} title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={modalEditUnlocked}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
@@ -805,14 +752,14 @@ export function CommunicationMethodsScreen() {
cardId={pendingCardId} cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById} blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={ blocksOverride={
modalEditUnlocked && draftFieldBlocks !== null draftFieldBlocks !== null ? draftFieldBlocks : undefined
? draftFieldBlocks
: undefined
} }
policyMeta={state.customMethodCardMetaById?.[pendingCardId]} policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked} showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={ onFieldBlocksChange={
fieldsLocked draftFieldBlocks === null
? undefined ? undefined
: (next) => setDraftFieldBlocks(next) : (next) => setDraftFieldBlocks(next)
} }
@@ -821,7 +768,6 @@ export function CommunicationMethodsScreen() {
<CommunicationMethodEditFields <CommunicationMethodEditFields
value={pendingDraft} value={pendingDraft}
onChange={handleDraftChange} onChange={handleDraftChange}
readOnly={fieldsLocked}
/> />
) )
) : null} ) : null}
@@ -830,6 +776,7 @@ export function CommunicationMethodsScreen() {
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard} onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) => onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment") uploadCreateFlowFile(file, "customMethodAttachment")
@@ -50,20 +50,20 @@ import type { ConflictManagementDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
export function ConflictManagementScreen() { export function ConflictManagementScreen() {
const m = useMessages(); const m = useMessages();
const cm = m.create.customRule.conflictManagement; const cm = m.create.customRule.conflictManagement;
const modalKebabMenu = m.create.customRule.modalKebabMenu; const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp(); const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } = const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } = const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow(); useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null); const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
@@ -76,12 +76,14 @@ export function ConflictManagementScreen() {
const [pendingDraft, setPendingDraft] = const [pendingDraft, setPendingDraft] =
useState<ConflictManagementDetailEntry | null>(null); useState<ConflictManagementDetailEntry | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const [modalEditUnlocked, setModalEditUnlocked] = useState(false); const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState<
string | null
>(null);
const [wizardInitialValues, setWizardInitialValues] =
useState<MethodCardWizardInitialValues | null>(null);
const [draftFieldBlocks, setDraftFieldBlocks] = useState< const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
useState<MethodCardHeaderDraft | null>(null);
const selectedIds = state.selectedConflictManagementIds ?? []; const selectedIds = state.selectedConflictManagementIds ?? [];
@@ -104,6 +106,8 @@ export function ConflictManagementScreen() {
const handleOpenAddWizard = useCallback(() => { const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
setAddCustomWizardOpen(true); setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]); }, [markCreateFlowInteraction]);
@@ -145,15 +149,40 @@ export function ConflictManagementScreen() {
const handleCardClick = useCallback( const handleCardClick = useCallback(
(id: string) => { (id: string) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
customizeSnapshotRef.current = null; const draft = seedDraft(id);
setModalEditUnlocked(false); const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
setDraftFieldBlocks(null); const initialBlocks =
setCustomizeHeaderDraft(null); 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); setPendingCardId(id);
setPendingDraft(seedDraft(id)); setPendingDraft(draft);
setDraftFieldBlocks(initialBlocks);
setCreateModalOpen(true); setCreateModalOpen(true);
}, },
[markCreateFlowInteraction, seedDraft], [
cm.confirmModal.description,
cm.confirmModal.title,
markCreateFlowInteraction,
methodById,
seedDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
],
); );
const handleDraftChange = useCallback( const handleDraftChange = useCallback(
@@ -166,9 +195,7 @@ export function ConflictManagementScreen() {
const isSelectedCardModal = const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId); pendingCardId !== null && selectedIds.includes(pendingCardId);
const fieldsLocked = !modalEditUnlocked; const showMethodModalPrimary = true;
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
const customFacetDetailsMatchPreset = useMemo(() => { const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false; if (!pendingCardId || !pendingDraft) return false;
@@ -190,7 +217,7 @@ export function ConflictManagementScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked, modalEditUnlocked: false,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -198,7 +225,6 @@ export function ConflictManagementScreen() {
[ [
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingCardId, pendingCardId,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -208,11 +234,11 @@ export function ConflictManagementScreen() {
const handleCreateModalClose = useCallback(async () => { const handleCreateModalClose = useCallback(async () => {
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -240,53 +266,14 @@ export function ConflictManagementScreen() {
setCreateModalOpen(false); setCreateModalOpen(false);
setPendingCardId(null); setPendingCardId(null);
setPendingDraft(null); setPendingDraft(null);
setModalEditUnlocked(false);
setDraftFieldBlocks(null); setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingDraft, pendingDraft,
replaceState, 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 () => { const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) { if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return; return;
@@ -294,11 +281,11 @@ export function ConflictManagementScreen() {
markCreateFlowInteraction(); markCreateFlowInteraction();
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -314,11 +301,9 @@ export function ConflictManagementScreen() {
await handleCreateModalClose(); await handleCreateModalClose();
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
pendingDraft, pendingDraft,
pendingCardId, pendingCardId,
selectedIds, selectedIds,
@@ -328,35 +313,36 @@ export function ConflictManagementScreen() {
const handleCustomize = useCallback(() => { const handleCustomize = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
if (!pendingDraft || !pendingCardId) { if (!pendingCardId) {
return; return;
} }
const initialFieldBlocks =
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? structuredClone(
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
)
: null;
const method = methodById.get(pendingCardId); const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId]; setWizardInitialValues(
const headerDraft: MethodCardHeaderDraft = { buildMethodCardWizardInitialValues({
title: meta?.label ?? method?.label ?? cm.confirmModal.title, cardId: pendingCardId,
description: fallbackTitle: method?.label ?? cm.confirmModal.title,
meta?.supportText ?? fallbackDescription:
method?.supportText ?? method?.supportText ?? cm.confirmModal.description,
cm.confirmModal.description, meta: state.customMethodCardMetaById,
}; persistedBlocks: state.customMethodCardFieldBlocksById,
setCustomizeHeaderDraft(headerDraft); draftFieldBlocks,
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( facetPrefill: pendingDraft
pendingDraft, ? {
initialFieldBlocks, group: "conflictManagement",
headerDraft, draft: pendingDraft,
headings: cm.sectionHeadings,
}
: undefined,
}),
); );
setDraftFieldBlocks(initialFieldBlocks); setWizardCustomizeCardId(pendingCardId);
setModalEditUnlocked(true); setCreateModalOpen(false);
setAddCustomWizardOpen(true);
}, [ }, [
cm.confirmModal.description, cm.confirmModal.description,
cm.confirmModal.title, cm.confirmModal.title,
cm.sectionHeadings,
draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
pendingCardId, pendingCardId,
@@ -381,9 +367,7 @@ export function ConflictManagementScreen() {
() => conflictManagementPresetFor(newId), () => conflictManagementPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -413,13 +397,12 @@ export function ConflictManagementScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
draftFieldBlocks, draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
@@ -448,9 +431,7 @@ export function ConflictManagementScreen() {
() => conflictManagementPresetFor(newId), () => conflictManagementPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -480,14 +461,13 @@ export function ConflictManagementScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
draftFieldBlocks, draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
@@ -500,7 +480,7 @@ export function ConflictManagementScreen() {
const kebabMenuItems = useMemo( const kebabMenuItems = useMemo(
() => () =>
buildCustomRuleModalKebabMenu(modalKebabMenu, { buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked, showCustomize: true,
onCustomize: handleCustomize, onCustomize: handleCustomize,
onDuplicate: onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
@@ -520,7 +500,6 @@ export function ConflictManagementScreen() {
handleDuplicatePrefabCard, handleDuplicatePrefabCard,
handleRemoveSelectedFromModal, handleRemoveSelectedFromModal,
isSelectedCardModal, isSelectedCardModal,
modalEditUnlocked,
modalKebabMenu, modalKebabMenu,
pendingCardId, pendingCardId,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -539,7 +518,7 @@ export function ConflictManagementScreen() {
meta?.supportText ?? meta?.supportText ??
method?.supportText ?? method?.supportText ??
cm.confirmModal.description, cm.confirmModal.description,
nextButtonText: modalEditUnlocked nextButtonText: isSelectedCardModal
? saveLabel ? saveLabel
: cm.addApproach.nextButtonText, : cm.addApproach.nextButtonText,
}; };
@@ -551,8 +530,14 @@ export function ConflictManagementScreen() {
}; };
const handleCloseAddWizard = useCallback(() => { const handleCloseAddWizard = useCallback(() => {
const resumeCardId = wizardCustomizeCardId;
setAddCustomWizardOpen(false); setAddCustomWizardOpen(false);
}, []); setWizardCustomizeCardId(null);
setWizardInitialValues(null);
if (resumeCardId && pendingCardId === resumeCardId) {
setCreateModalOpen(true);
}
}, [pendingCardId, wizardCustomizeCardId]);
const handleFinalizeCustomCard = useCallback( const handleFinalizeCustomCard = useCallback(
({ ({
@@ -565,6 +550,42 @@ export function ConflictManagementScreen() {
fieldBlocks: CustomMethodCardFieldBlock[]; fieldBlocks: CustomMethodCardFieldBlock[];
}) => { }) => {
markCreateFlowInteraction(); 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(); const id = crypto.randomUUID();
updateState({ updateState({
selectedConflictManagementIds: moveFacetSelectionIdToFront( selectedConflictManagementIds: moveFacetSelectionIdToFront(
@@ -587,91 +608,29 @@ export function ConflictManagementScreen() {
}, },
[ [
markCreateFlowInteraction, markCreateFlowInteraction,
pendingDraft,
selectedIds, selectedIds,
state.conflictManagementDetailsById, state.conflictManagementDetailsById,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
updateState, updateState,
wizardCustomizeCardId,
], ],
); );
const handleCreateModalPrimary = useCallback(() => { const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) { if (!pendingCardId) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
markCreateFlowInteraction(); markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) { const persistWizardBlocks =
if (modalEditUnlocked) { modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
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;
}
if (modalEditUnlocked) { if (selectedIds.includes(pendingCardId)) {
if (!customizeHeaderDraft) { if (persistWizardBlocks) {
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({ updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: { customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}), ...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []), [pendingCardId]: structuredClone(draftFieldBlocks ?? []),
@@ -679,22 +638,27 @@ export function ConflictManagementScreen() {
}); });
} else if (pendingDraft) { } else if (pendingDraft) {
updateState({ updateState({
customMethodCardMetaById: nextMeta,
conflictManagementDetailsById: { conflictManagementDetailsById: {
...(state.conflictManagementDetailsById ?? {}), ...(state.conflictManagementDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: pendingDraft,
}, },
}); });
} }
customizeSnapshotRef.current = null; if (pendingDraft) {
setModalEditUnlocked(false); customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
setDraftFieldBlocks(null); pendingDraft,
setCustomizeHeaderDraft(null); persistWizardBlocks ? draftFieldBlocks : null,
customizeSnapshotRef.current?.headerDraft ?? {
title: "",
description: "",
},
);
}
return; return;
} }
if (!pendingDraft) { if (!pendingDraft) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
updateState({ updateState({
@@ -706,15 +670,23 @@ export function ConflictManagementScreen() {
...(state.conflictManagementDetailsById ?? {}), ...(state.conflictManagementDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: pendingDraft,
}, },
...(persistWizardBlocks
? {
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
}
: {}),
}); });
pendingEphemeralDuplicateIdRef.current = null; pendingEphemeralDuplicateIdRef.current = null;
handleCreateModalClose(); customizeSnapshotRef.current = null;
void handleCreateModalClose();
}, [ }, [
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked, modalUsesWizardFieldBlocksBody,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
selectedIds, selectedIds,
@@ -765,33 +737,10 @@ export function ConflictManagementScreen() {
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
titleValue={customizeHeaderDraft.title}
descriptionValue={customizeHeaderDraft.description}
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={(description) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, description } : null,
)
}
/>
) : undefined
}
onNext={handleCreateModalPrimary} onNext={handleCreateModalPrimary}
title={modalConfig.title} title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={modalEditUnlocked}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
@@ -804,14 +753,14 @@ export function ConflictManagementScreen() {
cardId={pendingCardId} cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById} blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={ blocksOverride={
modalEditUnlocked && draftFieldBlocks !== null draftFieldBlocks !== null ? draftFieldBlocks : undefined
? draftFieldBlocks
: undefined
} }
policyMeta={state.customMethodCardMetaById?.[pendingCardId]} policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked} showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={ onFieldBlocksChange={
fieldsLocked draftFieldBlocks === null
? undefined ? undefined
: (next) => setDraftFieldBlocks(next) : (next) => setDraftFieldBlocks(next)
} }
@@ -820,7 +769,6 @@ export function ConflictManagementScreen() {
<ConflictManagementEditFields <ConflictManagementEditFields
value={pendingDraft} value={pendingDraft}
onChange={handleDraftChange} onChange={handleDraftChange}
readOnly={fieldsLocked}
/> />
) )
) : null} ) : null}
@@ -829,6 +777,7 @@ export function ConflictManagementScreen() {
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard} onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) => onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment") uploadCreateFlowFile(file, "customMethodAttachment")
@@ -51,20 +51,20 @@ import type { MembershipMethodDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
export function MembershipMethodsScreen() { export function MembershipMethodsScreen() {
const m = useMessages(); const m = useMessages();
const mem = m.create.customRule.membership; const mem = m.create.customRule.membership;
const modalKebabMenu = m.create.customRule.modalKebabMenu; const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp(); const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } = const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } = const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow(); useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null); const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
@@ -77,12 +77,14 @@ export function MembershipMethodsScreen() {
const [pendingDraft, setPendingDraft] = const [pendingDraft, setPendingDraft] =
useState<MembershipMethodDetailEntry | null>(null); useState<MembershipMethodDetailEntry | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const [modalEditUnlocked, setModalEditUnlocked] = useState(false); const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState<
string | null
>(null);
const [wizardInitialValues, setWizardInitialValues] =
useState<MethodCardWizardInitialValues | null>(null);
const [draftFieldBlocks, setDraftFieldBlocks] = useState< const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
useState<MethodCardHeaderDraft | null>(null);
const selectedIds = state.selectedMembershipMethodIds ?? []; const selectedIds = state.selectedMembershipMethodIds ?? [];
@@ -105,6 +107,8 @@ export function MembershipMethodsScreen() {
const handleOpenAddWizard = useCallback(() => { const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
setAddCustomWizardOpen(true); setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]); }, [markCreateFlowInteraction]);
@@ -142,15 +146,40 @@ export function MembershipMethodsScreen() {
const handleCardClick = useCallback( const handleCardClick = useCallback(
(id: string) => { (id: string) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
customizeSnapshotRef.current = null; const draft = seedDraft(id);
setModalEditUnlocked(false); const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
setDraftFieldBlocks(null); const initialBlocks =
setCustomizeHeaderDraft(null); 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); setPendingCardId(id);
setPendingDraft(seedDraft(id)); setPendingDraft(draft);
setDraftFieldBlocks(initialBlocks);
setCreateModalOpen(true); setCreateModalOpen(true);
}, },
[markCreateFlowInteraction, seedDraft], [
mem.confirmModal.description,
mem.confirmModal.title,
markCreateFlowInteraction,
methodById,
seedDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
],
); );
const handleDraftChange = useCallback( const handleDraftChange = useCallback(
@@ -163,9 +192,7 @@ export function MembershipMethodsScreen() {
const isSelectedCardModal = const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId); pendingCardId !== null && selectedIds.includes(pendingCardId);
const fieldsLocked = !modalEditUnlocked; const showMethodModalPrimary = true;
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
const customFacetDetailsMatchPreset = useMemo(() => { const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false; if (!pendingCardId || !pendingDraft) return false;
@@ -187,7 +214,7 @@ export function MembershipMethodsScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked, modalEditUnlocked: false,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -195,7 +222,6 @@ export function MembershipMethodsScreen() {
[ [
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingCardId, pendingCardId,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -205,11 +231,11 @@ export function MembershipMethodsScreen() {
const handleCreateModalClose = useCallback(async () => { const handleCreateModalClose = useCallback(async () => {
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -237,53 +263,14 @@ export function MembershipMethodsScreen() {
setCreateModalOpen(false); setCreateModalOpen(false);
setPendingCardId(null); setPendingCardId(null);
setPendingDraft(null); setPendingDraft(null);
setModalEditUnlocked(false);
setDraftFieldBlocks(null); setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingDraft, pendingDraft,
replaceState, 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 () => { const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) { if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return; return;
@@ -291,11 +278,11 @@ export function MembershipMethodsScreen() {
markCreateFlowInteraction(); markCreateFlowInteraction();
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -307,11 +294,9 @@ export function MembershipMethodsScreen() {
await handleCreateModalClose(); await handleCreateModalClose();
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
pendingDraft, pendingDraft,
pendingCardId, pendingCardId,
selectedIds, selectedIds,
@@ -321,35 +306,36 @@ export function MembershipMethodsScreen() {
const handleCustomize = useCallback(() => { const handleCustomize = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
if (!pendingDraft || !pendingCardId) { if (!pendingCardId) {
return; return;
} }
const initialFieldBlocks =
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? structuredClone(
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
)
: null;
const method = methodById.get(pendingCardId); const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId]; setWizardInitialValues(
const headerDraft: MethodCardHeaderDraft = { buildMethodCardWizardInitialValues({
title: meta?.label ?? method?.label ?? mem.confirmModal.title, cardId: pendingCardId,
description: fallbackTitle: method?.label ?? mem.confirmModal.title,
meta?.supportText ?? fallbackDescription:
method?.supportText ?? method?.supportText ?? mem.confirmModal.description,
mem.confirmModal.description, meta: state.customMethodCardMetaById,
}; persistedBlocks: state.customMethodCardFieldBlocksById,
setCustomizeHeaderDraft(headerDraft); draftFieldBlocks,
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( facetPrefill: pendingDraft
pendingDraft, ? {
initialFieldBlocks, group: "membership",
headerDraft, draft: pendingDraft,
headings: mem.sectionHeadings,
}
: undefined,
}),
); );
setDraftFieldBlocks(initialFieldBlocks); setWizardCustomizeCardId(pendingCardId);
setModalEditUnlocked(true); setCreateModalOpen(false);
setAddCustomWizardOpen(true);
}, [ }, [
mem.confirmModal.description, mem.confirmModal.description,
mem.confirmModal.title, mem.confirmModal.title,
mem.sectionHeadings,
draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
pendingCardId, pendingCardId,
@@ -374,9 +360,7 @@ export function MembershipMethodsScreen() {
() => membershipPresetFor(newId), () => membershipPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -406,13 +390,12 @@ export function MembershipMethodsScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
draftFieldBlocks, draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
@@ -441,9 +424,7 @@ export function MembershipMethodsScreen() {
() => membershipPresetFor(newId), () => membershipPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -473,14 +454,13 @@ export function MembershipMethodsScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
draftFieldBlocks, draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
@@ -493,7 +473,7 @@ export function MembershipMethodsScreen() {
const kebabMenuItems = useMemo( const kebabMenuItems = useMemo(
() => () =>
buildCustomRuleModalKebabMenu(modalKebabMenu, { buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked, showCustomize: true,
onCustomize: handleCustomize, onCustomize: handleCustomize,
onDuplicate: onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
@@ -513,7 +493,6 @@ export function MembershipMethodsScreen() {
handleDuplicatePrefabCard, handleDuplicatePrefabCard,
handleRemoveSelectedFromModal, handleRemoveSelectedFromModal,
isSelectedCardModal, isSelectedCardModal,
modalEditUnlocked,
modalKebabMenu, modalKebabMenu,
pendingCardId, pendingCardId,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -532,7 +511,7 @@ export function MembershipMethodsScreen() {
meta?.supportText ?? meta?.supportText ??
method?.supportText ?? method?.supportText ??
mem.confirmModal.description, mem.confirmModal.description,
nextButtonText: modalEditUnlocked nextButtonText: isSelectedCardModal
? saveLabel ? saveLabel
: mem.addPlatform.nextButtonText, : mem.addPlatform.nextButtonText,
}; };
@@ -544,8 +523,14 @@ export function MembershipMethodsScreen() {
}; };
const handleCloseAddWizard = useCallback(() => { const handleCloseAddWizard = useCallback(() => {
const resumeCardId = wizardCustomizeCardId;
setAddCustomWizardOpen(false); setAddCustomWizardOpen(false);
}, []); setWizardCustomizeCardId(null);
setWizardInitialValues(null);
if (resumeCardId && pendingCardId === resumeCardId) {
setCreateModalOpen(true);
}
}, [pendingCardId, wizardCustomizeCardId]);
const handleFinalizeCustomCard = useCallback( const handleFinalizeCustomCard = useCallback(
({ ({
@@ -558,6 +543,42 @@ export function MembershipMethodsScreen() {
fieldBlocks: CustomMethodCardFieldBlock[]; fieldBlocks: CustomMethodCardFieldBlock[];
}) => { }) => {
markCreateFlowInteraction(); 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(); const id = crypto.randomUUID();
updateState({ updateState({
selectedMembershipMethodIds: moveFacetSelectionIdToFront( selectedMembershipMethodIds: moveFacetSelectionIdToFront(
@@ -580,91 +601,29 @@ export function MembershipMethodsScreen() {
}, },
[ [
markCreateFlowInteraction, markCreateFlowInteraction,
pendingDraft,
selectedIds, selectedIds,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
state.membershipMethodDetailsById, state.membershipMethodDetailsById,
updateState, updateState,
wizardCustomizeCardId,
], ],
); );
const handleCreateModalPrimary = useCallback(() => { const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) { if (!pendingCardId) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
markCreateFlowInteraction(); markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) { const persistWizardBlocks =
if (modalEditUnlocked) { modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
if (!customizeHeaderDraft) {
return;
}
const nextMeta = methodCardMetaWithCustomizeHeader(
state.customMethodCardMetaById,
pendingCardId,
customizeHeaderDraft,
);
if (
pendingCardId &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
usesWizardFieldBlocksModalBody({
methodId: pendingCardId,
meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked,
draftFieldBlocks,
customFacetDetailsMatchPreset,
})
) {
updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
});
} else if (pendingDraft) {
updateState({
customMethodCardMetaById: nextMeta,
membershipMethodDetailsById: {
...(state.membershipMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
},
});
}
customizeSnapshotRef.current = null;
setModalEditUnlocked(false);
setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
return;
}
return;
}
if (modalEditUnlocked) { if (selectedIds.includes(pendingCardId)) {
if (!customizeHeaderDraft) { if (persistWizardBlocks) {
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({ updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: { customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}), ...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []), [pendingCardId]: structuredClone(draftFieldBlocks ?? []),
@@ -672,22 +631,27 @@ export function MembershipMethodsScreen() {
}); });
} else if (pendingDraft) { } else if (pendingDraft) {
updateState({ updateState({
customMethodCardMetaById: nextMeta,
membershipMethodDetailsById: { membershipMethodDetailsById: {
...(state.membershipMethodDetailsById ?? {}), ...(state.membershipMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: pendingDraft,
}, },
}); });
} }
customizeSnapshotRef.current = null; if (pendingDraft) {
setModalEditUnlocked(false); customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
setDraftFieldBlocks(null); pendingDraft,
setCustomizeHeaderDraft(null); persistWizardBlocks ? draftFieldBlocks : null,
customizeSnapshotRef.current?.headerDraft ?? {
title: "",
description: "",
},
);
}
return; return;
} }
if (!pendingDraft) { if (!pendingDraft) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
updateState({ updateState({
@@ -699,15 +663,23 @@ export function MembershipMethodsScreen() {
...(state.membershipMethodDetailsById ?? {}), ...(state.membershipMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: pendingDraft,
}, },
...(persistWizardBlocks
? {
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
}
: {}),
}); });
pendingEphemeralDuplicateIdRef.current = null; pendingEphemeralDuplicateIdRef.current = null;
handleCreateModalClose(); customizeSnapshotRef.current = null;
void handleCreateModalClose();
}, [ }, [
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked, modalUsesWizardFieldBlocksBody,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
selectedIds, selectedIds,
@@ -758,33 +730,10 @@ export function MembershipMethodsScreen() {
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
titleValue={customizeHeaderDraft.title}
descriptionValue={customizeHeaderDraft.description}
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={(description) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, description } : null,
)
}
/>
) : undefined
}
onNext={handleCreateModalPrimary} onNext={handleCreateModalPrimary}
title={modalConfig.title} title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={modalEditUnlocked}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
@@ -797,14 +746,14 @@ export function MembershipMethodsScreen() {
cardId={pendingCardId} cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById} blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={ blocksOverride={
modalEditUnlocked && draftFieldBlocks !== null draftFieldBlocks !== null ? draftFieldBlocks : undefined
? draftFieldBlocks
: undefined
} }
policyMeta={state.customMethodCardMetaById?.[pendingCardId]} policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked} showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={ onFieldBlocksChange={
fieldsLocked draftFieldBlocks === null
? undefined ? undefined
: (next) => setDraftFieldBlocks(next) : (next) => setDraftFieldBlocks(next)
} }
@@ -813,7 +762,6 @@ export function MembershipMethodsScreen() {
<MembershipMethodEditFields <MembershipMethodEditFields
value={pendingDraft} value={pendingDraft}
onChange={handleDraftChange} onChange={handleDraftChange}
readOnly={fieldsLocked}
/> />
) )
) : null} ) : null}
@@ -822,6 +770,7 @@ export function MembershipMethodsScreen() {
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard} onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) => onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment") uploadCreateFlowFile(file, "customMethodAttachment")
@@ -98,10 +98,10 @@ export function FinalReviewScreen({
* Two modals coexist on this screen: * Two modals coexist on this screen:
* *
* - {@link FinalReviewChipEditModal} — core values + method chips: kebab * - {@link FinalReviewChipEditModal} — core values + method chips: kebab
* Customize / Remove; values also offer Duplicate under the five-chip cap. * Remove; values also offer Duplicate under the five-chip cap; method chips
* Save respects the same unlock/dirty rules as the facet create modals; * offer Customize (prefilled custom-policy wizard). Fields are editable on
* writes `{group}DetailsById`, snapshot label (values), `customMethodCardMetaById`, * open. Save writes `{group}DetailsById` and field blocks; wizard Finalize
* and field blocks on Save. * also writes `customMethodCardMetaById`.
* - {@link TemplateChipDetailModal} — read-only fallback for chips we * - {@link TemplateChipDetailModal} — read-only fallback for chips we
* can't map to an override key (e.g. template body entries on the * can't map to an override key (e.g. template body entries on the
* "Use without changes" path where no preset matches the title). * "Use without changes" path where no preset matches the title).
@@ -51,20 +51,20 @@ import type { DecisionApproachDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch"; import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill";
import { import {
captureMethodCardCustomizeSnapshot, captureMethodCardCustomizeSnapshot,
type MethodCardCustomizeSnapshot, type MethodCardCustomizeSnapshot,
type MethodCardHeaderDraft, type MethodCardHeaderDraft,
} from "../../../../../lib/create/methodCardCustomizeSession"; } from "../../../../../lib/create/methodCardCustomizeSession";
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
export function DecisionApproachesScreen() { export function DecisionApproachesScreen() {
const m = useMessages(); const m = useMessages();
const da = m.create.customRule.decisionApproaches; const da = m.create.customRule.decisionApproaches;
const modalKebabMenu = m.create.customRule.modalKebabMenu; const modalKebabMenu = m.create.customRule.modalKebabMenu;
const mdUp = useCreateFlowMdUp(); const mdUp = useCreateFlowMdUp();
const { confirmDiscard, confirmDirtyCustomizeCancel, confirmDialog } = const { confirmDiscard, confirmDialog } = useDiscardCustomizeConfirm();
useDiscardCustomizeConfirm();
const { state, updateState, replaceState, markCreateFlowInteraction } = const { state, updateState, replaceState, markCreateFlowInteraction } =
useCreateFlow(); useCreateFlow();
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null); const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
@@ -80,12 +80,14 @@ export function DecisionApproachesScreen() {
const [pendingDraft, setPendingDraft] = const [pendingDraft, setPendingDraft] =
useState<DecisionApproachDetailEntry | null>(null); useState<DecisionApproachDetailEntry | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false); const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const [modalEditUnlocked, setModalEditUnlocked] = useState(false); const [wizardCustomizeCardId, setWizardCustomizeCardId] = useState<
string | null
>(null);
const [wizardInitialValues, setWizardInitialValues] =
useState<MethodCardWizardInitialValues | null>(null);
const [draftFieldBlocks, setDraftFieldBlocks] = useState< const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] | null CustomMethodCardFieldBlock[] | null
>(null); >(null);
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
useState<MethodCardHeaderDraft | null>(null);
const selectedIds = state.selectedDecisionApproachIds ?? []; const selectedIds = state.selectedDecisionApproachIds ?? [];
@@ -117,6 +119,8 @@ export function DecisionApproachesScreen() {
const handleOpenAddWizard = useCallback(() => { const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setWizardCustomizeCardId(null);
setWizardInitialValues(null);
setAddCustomWizardOpen(true); setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]); }, [markCreateFlowInteraction]);
@@ -158,15 +162,40 @@ export function DecisionApproachesScreen() {
const handleCardSelect = useCallback( const handleCardSelect = useCallback(
(id: string) => { (id: string) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
customizeSnapshotRef.current = null; const draft = seedDraft(id);
setModalEditUnlocked(false); const persistedBlocks = state.customMethodCardFieldBlocksById?.[id];
setDraftFieldBlocks(null); const initialBlocks =
setCustomizeHeaderDraft(null); 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); setPendingCardId(id);
setPendingDraft(seedDraft(id)); setPendingDraft(draft);
setDraftFieldBlocks(initialBlocks);
setCreateModalOpen(true); setCreateModalOpen(true);
}, },
[markCreateFlowInteraction, seedDraft], [
da.confirmModal.description,
da.confirmModal.title,
markCreateFlowInteraction,
methodById,
seedDraft,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
],
); );
const handleDraftChange = useCallback( const handleDraftChange = useCallback(
@@ -179,9 +208,7 @@ export function DecisionApproachesScreen() {
const isSelectedCardModal = const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId); pendingCardId !== null && selectedIds.includes(pendingCardId);
const fieldsLocked = !modalEditUnlocked; const showMethodModalPrimary = true;
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
const customFacetDetailsMatchPreset = useMemo(() => { const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false; if (!pendingCardId || !pendingDraft) return false;
@@ -203,7 +230,7 @@ export function DecisionApproachesScreen() {
methodId: pendingCardId, methodId: pendingCardId,
meta: state.customMethodCardMetaById, meta: state.customMethodCardMetaById,
fieldBlocksById: state.customMethodCardFieldBlocksById, fieldBlocksById: state.customMethodCardFieldBlocksById,
modalEditUnlocked, modalEditUnlocked: false,
draftFieldBlocks, draftFieldBlocks,
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
}), }),
@@ -211,7 +238,6 @@ export function DecisionApproachesScreen() {
[ [
customFacetDetailsMatchPreset, customFacetDetailsMatchPreset,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingCardId, pendingCardId,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -221,11 +247,11 @@ export function DecisionApproachesScreen() {
const handleCreateModalClose = useCallback(async () => { const handleCreateModalClose = useCallback(async () => {
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -253,53 +279,14 @@ export function DecisionApproachesScreen() {
setCreateModalOpen(false); setCreateModalOpen(false);
setPendingCardId(null); setPendingCardId(null);
setPendingDraft(null); setPendingDraft(null);
setModalEditUnlocked(false);
setDraftFieldBlocks(null); setDraftFieldBlocks(null);
setCustomizeHeaderDraft(null);
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
modalEditUnlocked,
pendingDraft, pendingDraft,
replaceState, 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 () => { const handleRemoveSelectedFromModal = useCallback(async () => {
if (!pendingCardId || !selectedIds.includes(pendingCardId)) { if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
return; return;
@@ -307,11 +294,11 @@ export function DecisionApproachesScreen() {
markCreateFlowInteraction(); markCreateFlowInteraction();
if ( if (
!(await confirmDiscard( !(await confirmDiscard(
modalEditUnlocked, true,
customizeSnapshotRef.current, customizeSnapshotRef.current,
pendingDraft, pendingDraft,
draftFieldBlocks, draftFieldBlocks,
customizeHeaderDraft, customizeSnapshotRef.current?.headerDraft ?? null,
)) ))
) { ) {
return; return;
@@ -327,11 +314,9 @@ export function DecisionApproachesScreen() {
await handleCreateModalClose(); await handleCreateModalClose();
}, [ }, [
confirmDiscard, confirmDiscard,
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
pendingDraft, pendingDraft,
pendingCardId, pendingCardId,
selectedIds, selectedIds,
@@ -341,35 +326,36 @@ export function DecisionApproachesScreen() {
const handleCustomize = useCallback(() => { const handleCustomize = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
if (!pendingDraft || !pendingCardId) { if (!pendingCardId) {
return; return;
} }
const initialFieldBlocks =
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? structuredClone(
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
)
: null;
const method = methodById.get(pendingCardId); const method = methodById.get(pendingCardId);
const meta = state.customMethodCardMetaById?.[pendingCardId]; setWizardInitialValues(
const headerDraft: MethodCardHeaderDraft = { buildMethodCardWizardInitialValues({
title: meta?.label ?? method?.label ?? da.confirmModal.title, cardId: pendingCardId,
description: fallbackTitle: method?.label ?? da.confirmModal.title,
meta?.supportText ?? fallbackDescription:
method?.supportText ?? method?.supportText ?? da.confirmModal.description,
da.confirmModal.description, meta: state.customMethodCardMetaById,
}; persistedBlocks: state.customMethodCardFieldBlocksById,
setCustomizeHeaderDraft(headerDraft); draftFieldBlocks,
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot( facetPrefill: pendingDraft
pendingDraft, ? {
initialFieldBlocks, group: "decisionApproaches",
headerDraft, draft: pendingDraft,
headings: da.sectionHeadings,
}
: undefined,
}),
); );
setDraftFieldBlocks(initialFieldBlocks); setWizardCustomizeCardId(pendingCardId);
setModalEditUnlocked(true); setCreateModalOpen(false);
setAddCustomWizardOpen(true);
}, [ }, [
da.confirmModal.description, da.confirmModal.description,
da.confirmModal.title, da.confirmModal.title,
da.sectionHeadings,
draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
pendingCardId, pendingCardId,
@@ -394,9 +380,7 @@ export function DecisionApproachesScreen() {
() => decisionApproachPresetFor(newId), () => decisionApproachPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -426,13 +410,12 @@ export function DecisionApproachesScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
draftFieldBlocks, draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
@@ -461,9 +444,7 @@ export function DecisionApproachesScreen() {
() => decisionApproachPresetFor(newId), () => decisionApproachPresetFor(newId),
); );
const blocksClone = structuredClone( const blocksClone = structuredClone(
modalEditUnlocked && draftFieldBlocks !== null
draftFieldBlocks !== null &&
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
? draftFieldBlocks ? draftFieldBlocks
: cloneMethodCardBlocksForDuplicate( : cloneMethodCardBlocksForDuplicate(
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
@@ -493,14 +474,13 @@ export function DecisionApproachesScreen() {
customizeSnapshotRef.current = null; customizeSnapshotRef.current = null;
setPendingCardId(newId); setPendingCardId(newId);
setPendingDraft(structuredClone(detailsClone)); setPendingDraft(structuredClone(detailsClone));
setModalEditUnlocked(false); setDraftFieldBlocks(
setDraftFieldBlocks(null); blocksClone.length > 0 ? structuredClone(blocksClone) : null,
setCustomizeHeaderDraft(null); );
}, [ }, [
draftFieldBlocks, draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
methodById, methodById,
modalEditUnlocked,
modalKebabMenu.duplicateTitleSuffix, modalKebabMenu.duplicateTitleSuffix,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
@@ -513,7 +493,7 @@ export function DecisionApproachesScreen() {
const kebabMenuItems = useMemo( const kebabMenuItems = useMemo(
() => () =>
buildCustomRuleModalKebabMenu(modalKebabMenu, { buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: !modalEditUnlocked, showCustomize: true,
onCustomize: handleCustomize, onCustomize: handleCustomize,
onDuplicate: onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId (state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
@@ -533,7 +513,6 @@ export function DecisionApproachesScreen() {
handleDuplicatePrefabCard, handleDuplicatePrefabCard,
handleRemoveSelectedFromModal, handleRemoveSelectedFromModal,
isSelectedCardModal, isSelectedCardModal,
modalEditUnlocked,
modalKebabMenu, modalKebabMenu,
pendingCardId, pendingCardId,
state.customMethodCardMetaById, state.customMethodCardMetaById,
@@ -547,8 +526,14 @@ export function DecisionApproachesScreen() {
}, [markCreateFlowInteraction]); }, [markCreateFlowInteraction]);
const handleCloseAddWizard = useCallback(() => { const handleCloseAddWizard = useCallback(() => {
const resumeCardId = wizardCustomizeCardId;
setAddCustomWizardOpen(false); setAddCustomWizardOpen(false);
}, []); setWizardCustomizeCardId(null);
setWizardInitialValues(null);
if (resumeCardId && pendingCardId === resumeCardId) {
setCreateModalOpen(true);
}
}, [pendingCardId, wizardCustomizeCardId]);
const handleFinalizeCustomCard = useCallback( const handleFinalizeCustomCard = useCallback(
({ ({
@@ -561,6 +546,42 @@ export function DecisionApproachesScreen() {
fieldBlocks: CustomMethodCardFieldBlock[]; fieldBlocks: CustomMethodCardFieldBlock[];
}) => { }) => {
markCreateFlowInteraction(); 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(); const id = crypto.randomUUID();
updateState({ updateState({
selectedDecisionApproachIds: moveFacetSelectionIdToFront( selectedDecisionApproachIds: moveFacetSelectionIdToFront(
@@ -583,91 +604,29 @@ export function DecisionApproachesScreen() {
}, },
[ [
markCreateFlowInteraction, markCreateFlowInteraction,
pendingDraft,
selectedIds, selectedIds,
state.customMethodCardFieldBlocksById, state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById, state.customMethodCardMetaById,
state.decisionApproachDetailsById, state.decisionApproachDetailsById,
updateState, updateState,
wizardCustomizeCardId,
], ],
); );
const handleCreateModalPrimary = useCallback(() => { const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) { if (!pendingCardId) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
markCreateFlowInteraction(); markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) { const persistWizardBlocks =
if (modalEditUnlocked) { modalUsesWizardFieldBlocksBody && draftFieldBlocks !== null;
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;
}
if (modalEditUnlocked) { if (selectedIds.includes(pendingCardId)) {
if (!customizeHeaderDraft) { if (persistWizardBlocks) {
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({ updateState({
customMethodCardMetaById: nextMeta,
customMethodCardFieldBlocksById: { customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}), ...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []), [pendingCardId]: structuredClone(draftFieldBlocks ?? []),
@@ -675,22 +634,27 @@ export function DecisionApproachesScreen() {
}); });
} else if (pendingDraft) { } else if (pendingDraft) {
updateState({ updateState({
customMethodCardMetaById: nextMeta,
decisionApproachDetailsById: { decisionApproachDetailsById: {
...(state.decisionApproachDetailsById ?? {}), ...(state.decisionApproachDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: pendingDraft,
}, },
}); });
} }
customizeSnapshotRef.current = null; if (pendingDraft) {
setModalEditUnlocked(false); customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
setDraftFieldBlocks(null); pendingDraft,
setCustomizeHeaderDraft(null); persistWizardBlocks ? draftFieldBlocks : null,
customizeSnapshotRef.current?.headerDraft ?? {
title: "",
description: "",
},
);
}
return; return;
} }
if (!pendingDraft) { if (!pendingDraft) {
handleCreateModalClose(); void handleCreateModalClose();
return; return;
} }
updateState({ updateState({
@@ -702,15 +666,23 @@ export function DecisionApproachesScreen() {
...(state.decisionApproachDetailsById ?? {}), ...(state.decisionApproachDetailsById ?? {}),
[pendingCardId]: pendingDraft, [pendingCardId]: pendingDraft,
}, },
...(persistWizardBlocks
? {
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
},
}
: {}),
}); });
pendingEphemeralDuplicateIdRef.current = null; pendingEphemeralDuplicateIdRef.current = null;
handleCreateModalClose(); customizeSnapshotRef.current = null;
void handleCreateModalClose();
}, [ }, [
customizeHeaderDraft,
draftFieldBlocks, draftFieldBlocks,
handleCreateModalClose, handleCreateModalClose,
markCreateFlowInteraction, markCreateFlowInteraction,
modalEditUnlocked, modalUsesWizardFieldBlocksBody,
pendingCardId, pendingCardId,
pendingDraft, pendingDraft,
selectedIds, selectedIds,
@@ -729,7 +701,7 @@ export function DecisionApproachesScreen() {
meta?.supportText ?? meta?.supportText ??
method?.supportText ?? method?.supportText ??
da.confirmModal.description, da.confirmModal.description,
nextButtonText: modalEditUnlocked nextButtonText: isSelectedCardModal
? saveLabel ? saveLabel
: da.addApproach.nextButtonText, : da.addApproach.nextButtonText,
}; };
@@ -802,33 +774,10 @@ export function DecisionApproachesScreen() {
<Create <Create
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} onClose={handleCreateModalClose}
headerContent={
modalEditUnlocked && customizeHeaderDraft ? (
<MethodCardCustomizeModalHeader
titleLabel={modalKebabMenu.customizePolicyTitleLabel}
descriptionLabel={modalKebabMenu.customizePolicyDescriptionLabel}
titleValue={customizeHeaderDraft.title}
descriptionValue={customizeHeaderDraft.description}
onTitleChange={(title) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, title } : null,
)
}
onDescriptionChange={(description) =>
setCustomizeHeaderDraft((prev) =>
prev ? { ...prev, description } : null,
)
}
/>
) : undefined
}
onNext={handleCreateModalPrimary} onNext={handleCreateModalPrimary}
title={modalConfig.title} title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} nextButtonText={modalConfig.nextButtonText}
showBackButton={modalEditUnlocked}
onBack={handleCancelCustomize}
backButtonText={modalKebabMenu.cancelCustomize}
showNextButton={showMethodModalPrimary} showNextButton={showMethodModalPrimary}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
@@ -841,14 +790,14 @@ export function DecisionApproachesScreen() {
cardId={pendingCardId} cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById} blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={ blocksOverride={
modalEditUnlocked && draftFieldBlocks !== null draftFieldBlocks !== null ? draftFieldBlocks : undefined
? draftFieldBlocks
: undefined
} }
policyMeta={state.customMethodCardMetaById?.[pendingCardId]} policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked} showPolicyContentLockupWhenNoBlocks={
draftFieldBlocks === null || draftFieldBlocks.length === 0
}
onFieldBlocksChange={ onFieldBlocksChange={
fieldsLocked draftFieldBlocks === null
? undefined ? undefined
: (next) => setDraftFieldBlocks(next) : (next) => setDraftFieldBlocks(next)
} }
@@ -857,7 +806,6 @@ export function DecisionApproachesScreen() {
<DecisionApproachEditFields <DecisionApproachEditFields
value={pendingDraft} value={pendingDraft}
onChange={handleDraftChange} onChange={handleDraftChange}
readOnly={fieldsLocked}
/> />
) )
) : null} ) : null}
@@ -866,6 +814,7 @@ export function DecisionApproachesScreen() {
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard} onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) => onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment") uploadCreateFlowFile(file, "customMethodAttachment")
@@ -16,13 +16,23 @@ import type {
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { CoreValueEditFields } from "../../components/methodEditFields"; 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 { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
import { import {
duplicateCoreValueChipInDraft, duplicateCoreValueChipInDraft,
MAX_SELECTED_CORE_VALUES, MAX_SELECTED_CORE_VALUES,
removeCoreValueChipFromDraft, removeCoreValueChipFromDraft,
} from "../../../../../lib/create/coreValueChipFacet"; } 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; const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
@@ -113,6 +123,15 @@ export function CoreValuesSelectScreen() {
); );
const [modalSession, setModalSession] = useState<ModalSession | null>(null); const [modalSession, setModalSession] = useState<ModalSession | null>(null);
const [draft, setDraft] = useState<CoreValueDetailEntry>(EMPTY_DETAIL); const [draft, setDraft] = useState<CoreValueDetailEntry>(EMPTY_DETAIL);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const [wizardCustomizeChipId, setWizardCustomizeChipId] = useState<
string | null
>(null);
const [wizardInitialValues, setWizardInitialValues] =
useState<MethodCardWizardInitialValues | null>(null);
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[] | null
>(null);
useEffect(() => { useEffect(() => {
setCoreValueOptions( setCoreValueOptions(
@@ -165,6 +184,7 @@ export function CoreValuesSelectScreen() {
return { return {
meaning: saved?.meaning ?? preset.meaning, meaning: saved?.meaning ?? preset.meaning,
signals: saved?.signals ?? preset.signals, signals: saved?.signals ?? preset.signals,
...(saved?.supportText ? { supportText: saved.supportText } : {}),
}; };
}, },
[state.coreValueDetailsByChipId, getPresetTexts], [state.coreValueDetailsByChipId, getPresetTexts],
@@ -179,12 +199,22 @@ export function CoreValuesSelectScreen() {
) => { ) => {
const initial = seedDetail ?? getInitialTexts(chipId, valueLabel); const initial = seedDetail ?? getInitialTexts(chipId, valueLabel);
initialDraftRef.current = { ...initial }; initialDraftRef.current = { ...initial };
const persisted = state.customMethodCardFieldBlocksById?.[chipId];
setDraftFieldBlocks(
Array.isArray(persisted) && persisted.length > 0
? structuredClone(persisted)
: null,
);
setDraft(initial); setDraft(initial);
setActiveModalChipId(chipId); setActiveModalChipId(chipId);
setModalSession(session); setModalSession(session);
markCreateFlowInteraction(); markCreateFlowInteraction();
}, },
[getInitialTexts, markCreateFlowInteraction], [
getInitialTexts,
markCreateFlowInteraction,
state.customMethodCardFieldBlocksById,
],
); );
const handleDraftChange = useCallback( const handleDraftChange = useCallback(
@@ -200,19 +230,19 @@ export function CoreValuesSelectScreen() {
initialDraftRef.current = null; initialDraftRef.current = null;
setActiveModalChipId(null); setActiveModalChipId(null);
setModalSession(null); setModalSession(null);
setDraftFieldBlocks(null);
}, []); }, []);
const confirmLeaveWithoutSaving = useCallback(async () => { const confirmLeaveWithoutSaving = useCallback(async () => {
const isPendingAdd =
modalSession === "pending" || modalSession === "customPending";
const initial = initialDraftRef.current; const initial = initialDraftRef.current;
const editingDirty = const fieldsDirty =
modalSession === "editing" &&
initial != null && initial != null &&
(draft.meaning !== initial.meaning || draft.signals !== initial.signals); (draft.meaning !== initial.meaning || draft.signals !== initial.signals);
if (!isPendingAdd && !editingDirty) { if (!fieldsDirty) {
return true; return true;
} }
const isPendingAdd =
modalSession === "pending" || modalSession === "customPending";
return requestConfirm({ return requestConfirm({
title: cv.detailModal.discardTitle, title: cv.detailModal.discardTitle,
description: isPendingAdd description: isPendingAdd
@@ -294,11 +324,7 @@ export function CoreValuesSelectScreen() {
markCreateFlowInteraction(); markCreateFlowInteraction();
replaceState((prev) => ({ replaceState((prev) => ({
...prev, ...prev,
selectedCoreValueIds: selectedIdsFromOptions(nextFiltered), ...removeCoreValueChipFromDraft(prev, activeModalChipId),
coreValuesChipsSnapshot:
chipOptionsToSnapshotRows(nextFiltered),
coreValueDetailsByChipId:
omitIdFromStringRecord(prev.coreValueDetailsByChipId, activeModalChipId),
})); }));
setCoreValueOptions(nextFiltered); setCoreValueOptions(nextFiltered);
} }
@@ -355,48 +381,185 @@ export function CoreValuesSelectScreen() {
if (!activeModalChipId || !modalSession) return; if (!activeModalChipId || !modalSession) return;
markCreateFlowInteraction(); markCreateFlowInteraction();
pendingEphemeralCoreDuplicateRef.current = null; pendingEphemeralCoreDuplicateRef.current = null;
const existingBlocks =
draftFieldBlocks && draftFieldBlocks.length > 0
? draftFieldBlocks
: state.customMethodCardFieldBlocksById?.[activeModalChipId];
updateState({ updateState({
coreValueDetailsByChipId: { coreValueDetailsByChipId: {
...(state.coreValueDetailsByChipId ?? {}), ...(state.coreValueDetailsByChipId ?? {}),
[activeModalChipId]: draft, [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(); finalizeModalDismiss();
}, [ }, [
activeModalChipId, activeModalChipId,
cv.detailModal.meaningLabel,
cv.detailModal.signalsLabel,
draft, draft,
draftFieldBlocks,
finalizeModalDismiss, finalizeModalDismiss,
markCreateFlowInteraction, markCreateFlowInteraction,
modalSession, modalSession,
state.coreValueDetailsByChipId, state.coreValueDetailsByChipId,
state.customMethodCardFieldBlocksById,
updateState, updateState,
]); ]);
const modalChipLabel = const modalChipLabel =
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? ""; 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 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(() => { const kebabMenuItems = useMemo(() => {
if (!modalSession || !activeModalChipId) return []; if (!modalSession || !activeModalChipId) return [];
const selectedCount = coreValueOptions.filter( const selectedCount = coreValueOptions.filter(
(o) => o.state === "selected", (o) => o.state === "selected",
).length; ).length;
return buildCustomRuleModalKebabMenu(modalKebabMenu, { return buildCustomRuleModalKebabMenu(modalKebabMenu, {
showCustomize: true,
onCustomize: handleCustomize,
onDuplicate: onDuplicate:
modalSession !== "editing" || selectedCount >= MAX_CORE_VALUES (state.editingPublishedRuleId?.trim() ?? "") !== "" ||
selectedCount >= MAX_CORE_VALUES
? undefined ? undefined
: handleDuplicateCoreChip, : handleDuplicateCoreChip,
showRemove: true, showRemove: modalSession === "editing",
onRemove: handleRemoveFromKebab, onRemove: handleRemoveFromKebab,
}); });
}, [ }, [
activeModalChipId, activeModalChipId,
coreValueOptions, coreValueOptions,
handleCustomize,
handleDuplicateCoreChip, handleDuplicateCoreChip,
handleRemoveFromKebab, handleRemoveFromKebab,
modalKebabMenu, modalKebabMenu,
modalSession, modalSession,
state.editingPublishedRuleId,
]); ]);
const handleChipClick = (chipId: string) => { const handleChipClick = (chipId: string) => {
const target = coreValueOptions.find((o) => o.id === chipId); const target = coreValueOptions.find((o) => o.id === chipId);
@@ -520,14 +683,16 @@ export function CoreValuesSelectScreen() {
{detailModal && ( {detailModal && (
<Create <Create
isOpen={activeModalChipId !== null} isOpen={activeModalChipId !== null && !addCustomWizardOpen}
onClose={handleModalDismiss} onClose={handleModalDismiss}
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
headerContent={ headerContent={
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0"> <div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup <ContentLockup
title={modalChipLabel} title={modalChipLabel}
description={detailModal.subtitle} description={
draft.supportText?.trim() || detailModal.subtitle
}
variant="modal" variant="modal"
alignment="left" alignment="left"
/> />
@@ -548,13 +713,43 @@ export function CoreValuesSelectScreen() {
} }
ariaLabel={modalChipLabel || "Core value details"} ariaLabel={modalChipLabel || "Core value details"}
> >
{modalUsesWizardFieldBlocksBody && activeModalChipId ? (
<CustomMethodCardModalBody
cardId={activeModalChipId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
showPolicyContentLockupWhenNoBlocks={false}
onFieldBlocksChange={
draftFieldBlocks === null
? undefined
: (next) => {
setDraftFieldBlocks(next);
setDraft(
coreValueDetailsFromWizardFieldBlocks(next, draft),
);
}
}
/>
) : (
<CoreValueEditFields <CoreValueEditFields
value={draft} value={draft}
onChange={handleDraftChange} onChange={handleDraftChange}
/> />
)}
</Create> </Create>
)} )}
</CreateFlowTwoColumnSelectShell> </CreateFlowTwoColumnSelectShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
initialValues={wizardInitialValues}
onFinalize={handleFinalizeCustomCard}
onPersistCustomUploadFile={(file) =>
uploadCreateFlowFile(file, "customMethodAttachment")
}
/>
{confirmDialog} {confirmDialog}
</> </>
); );
+1
View File
@@ -59,6 +59,7 @@ export type CommunityStructureChipSnapshotRow = {
export type CoreValueDetailEntry = { export type CoreValueDetailEntry = {
meaning: string; meaning: string;
signals: string; signals: string;
supportText?: string;
}; };
/** /**
@@ -21,7 +21,13 @@ export function readCoreValueDetailsFromLocalStorage(): Record<
if (typeof o.meaning !== "string" || typeof o.signals !== "string") { if (typeof o.meaning !== "string" || typeof o.signals !== "string") {
continue; 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; return out;
} catch { } catch {
@@ -69,7 +69,6 @@ export function applyFinalReviewChipEditPatch(
} }
: {}; : {};
if ( if (
patch.groupKey !== "coreValues" &&
"customMethodCardFieldBlocks" in patch && "customMethodCardFieldBlocks" in patch &&
patch.customMethodCardFieldBlocks !== undefined patch.customMethodCardFieldBlocks !== undefined
) { ) {
+21
View File
@@ -24,6 +24,15 @@ export function removeCoreValueChipFromDraft(
const nextDetails = hadDetail const nextDetails = hadDetail
? omitIdFromStringRecord(state.coreValueDetailsByChipId, chipId) ? omitIdFromStringRecord(state.coreValueDetailsByChipId, chipId)
: undefined; : undefined;
const hadBlocks =
Boolean(state.customMethodCardFieldBlocksById) &&
Object.prototype.hasOwnProperty.call(
state.customMethodCardFieldBlocksById,
chipId,
);
const nextBlocks = hadBlocks
? omitIdFromStringRecord(state.customMethodCardFieldBlocksById, chipId)
: undefined;
const out: Partial<CreateFlowState> = { const out: Partial<CreateFlowState> = {
coreValuesChipsSnapshot: nextSnap, coreValuesChipsSnapshot: nextSnap,
@@ -33,6 +42,9 @@ export function removeCoreValueChipFromDraft(
if (hadDetail) { if (hadDetail) {
out.coreValueDetailsByChipId = nextDetails; out.coreValueDetailsByChipId = nextDetails;
} }
if (hadBlocks) {
out.customMethodCardFieldBlocksById = nextBlocks;
}
return out; return out;
} }
@@ -77,6 +89,7 @@ export function duplicateCoreValueChipInDraft(
[newId]: structuredClone(inherited), [newId]: structuredClone(inherited),
} }
: { ...(state.coreValueDetailsByChipId ?? {}) }; : { ...(state.coreValueDetailsByChipId ?? {}) };
const inheritedBlocks = state.customMethodCardFieldBlocksById?.[chipId];
return { return {
newId, newId,
@@ -87,6 +100,14 @@ export function duplicateCoreValueChipInDraft(
...(Object.keys(nextDetails).length > 0 ...(Object.keys(nextDetails).length > 0
? { coreValueDetailsByChipId: nextDetails } ? { coreValueDetailsByChipId: nextDetails }
: {}), : {}),
...(inheritedBlocks !== undefined
? {
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[newId]: structuredClone(inheritedBlocks),
},
}
: {}),
}, },
}; };
} }
@@ -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; 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;
+391
View File
@@ -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<string>();
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,
};
}
+6 -4
View File
@@ -14,6 +14,9 @@ import { isCustomMethodCardId } from "./isCustomMethodCardId";
* stubs keep the facet's structured edit fields until the user adds blocks (then this * stubs keep the facet's structured edit fields until the user adds blocks (then this
* returns true once persisted blocks are non-empty). * 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 * **View mode** (`modalEditUnlocked` false): when the custom card still has facet copy
* that matches preset seeds only (see `./methodCardFacetMatchesPresetForId`), route to * that matches preset seeds only (see `./methodCardFacetMatchesPresetForId`), route to
* {@link CustomMethodCardModalBody} so meta-only wizard cards show policy copy instead * {@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) { if (Array.isArray(persisted) && persisted.length > 0) {
return true; return true;
} }
if (!isCustomMethodCardId(args.methodId, args.meta)) {
return false;
}
if ( if (
args.modalEditUnlocked &&
args.draftFieldBlocks !== null && args.draftFieldBlocks !== null &&
args.draftFieldBlocks.length > 0 args.draftFieldBlocks.length > 0
) { ) {
return true; return true;
} }
if (!isCustomMethodCardId(args.methodId, args.meta)) {
return false;
}
return ( return (
!args.modalEditUnlocked && args.customFacetDetailsMatchPreset === true !args.modalEditUnlocked && args.customFacetDetailsMatchPreset === true
); );
@@ -25,6 +25,7 @@ const communityStructureChipSnapshotsSchema = z
const coreValueDetailEntrySchema = z.object({ const coreValueDetailEntrySchema = z.object({
meaning: z.string().max(8000), meaning: z.string().max(8000),
signals: z.string().max(8000), signals: z.string().max(8000),
supportText: z.string().max(2000).optional(),
}); });
/** /**
@@ -46,6 +46,7 @@
}, },
"fieldModals": { "fieldModals": {
"addField": "Add field", "addField": "Add field",
"saveField": "Save field",
"requiredHint": "Required", "requiredHint": "Required",
"text": { "text": {
"title": "Add text block", "title": "Add text block",
@@ -75,20 +75,16 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
); );
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); const textboxes = within(dialog).getAllByRole("textbox");
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); expect(textboxes.length).toBe(3);
const corePrincipleField = textboxes[0] as HTMLTextAreaElement;
const textboxes = within(screen.getByRole("dialog")).getAllByRole("textbox");
expect(textboxes.length).toBe(5);
const corePrincipleField = textboxes[2] as HTMLTextAreaElement;
// Preset corePrinciple must seed into the first body textarea so the user // Preset corePrinciple must seed into the first body textarea so the user
// edits a real starting point rather than an empty field. // edits a real starting point rather than an empty field.
expect(corePrincipleField.value.length).toBeGreaterThan(0); expect(corePrincipleField.value.length).toBeGreaterThan(0);
fireEvent.change(corePrincipleField, { target: { value: "Custom principle" } }); fireEvent.change(corePrincipleField, { target: { value: "Custom principle" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
fireEvent.click( fireEvent.click(
within(screen.getByRole("dialog")).getByRole("button", { within(dialog).getByRole("button", {
name: "Add Platform", name: "Add Platform",
}), }),
); );
@@ -162,7 +158,30 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
expect(textareas[2].value).toBe("Saved coc"); 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(
<ScreenWithStateProbe
onState={() => {
/* 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 = {}; let latest: CreateFlowState = {};
render( render(
<ScreenWithStateProbe <ScreenWithStateProbe
@@ -179,20 +198,84 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect( expect(
(within(screen.getByRole("dialog")).getAllByRole( await screen.findByPlaceholderText("Policy name"),
"textbox", ).toBeInTheDocument();
)[0] as HTMLTextAreaElement).disabled, fireEvent.keyDown(document, { key: "Escape" });
).toBe(true);
await waitFor(() => {
expect(screen.queryByPlaceholderText("Policy name")).not.toBeInTheDocument();
}); });
expect(latest.communicationMethodDetailsById).toBeUndefined(); expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Discard" })).not.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(
<ScreenWithStateProbe
onState={(s) => {
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(
<ScreenWithStateProbe
onState={() => {
/* 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 = {}; let latest: CreateFlowState = {};
render( render(
<ScreenWithStateProbe <ScreenWithStateProbe
@@ -216,67 +299,22 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0], screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
); );
const dialog = await screen.findByRole("dialog"); 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( const textboxes = within(dialog).getAllByRole(
"textbox", "textbox",
) as HTMLTextAreaElement[]; ) as HTMLTextAreaElement[];
fireEvent.change(textboxes[2], { target: { value: "Edited principle" } }); fireEvent.change(textboxes[0], { target: { value: "Edited principle" } });
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await confirmDiscardCustomizeEdits(); await confirmDiscardCustomizeEdits();
await waitFor(() => { await waitFor(() => {
expect( expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
(
within(screen.getByRole("dialog")).getAllByRole(
"textbox",
)[0] as HTMLTextAreaElement
).value,
).toBe("Saved principle");
}); });
expect( expect(
latest.communicationMethodDetailsById?.signal?.corePrinciple, latest.communicationMethodDetailsById?.signal?.corePrinciple,
).toBe("Saved principle"); ).toBe("Saved principle");
}); });
it("dirty Escape close stays open when user declines discard confirm", async () => { it("Customize wizard prefills the card title and persists a rename on Finalize", async () => {
render(
<ScreenWithStateProbe
onState={() => {
/* 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 () => {
const customId = "00000000-0000-4000-8000-0000000000aa"; const customId = "00000000-0000-4000-8000-0000000000aa";
let latest: CreateFlowState = {}; let latest: CreateFlowState = {};
render( render(
@@ -307,13 +345,21 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
const titleInput = within(screen.getByRole("dialog")).getAllByRole( expect(
"textbox", await screen.findByPlaceholderText("Policy name"),
)[0] as HTMLInputElement; ).toBeInTheDocument();
fireEvent.change(titleInput, { target: { value: "Renamed policy" } }); const nameInput = screen.getByPlaceholderText("Policy name");
fireEvent.click( expect(nameInput).toHaveValue("Original title");
within(screen.getByRole("dialog")).getByRole("button", { name: "Save" }), 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(() => { await waitFor(() => {
expect(latest.customMethodCardMetaById?.[customId]?.label).toBe( 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 = {}; let latest: CreateFlowState = {};
render( render(
<ScreenWithStateProbe <ScreenWithStateProbe
@@ -339,15 +385,32 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
const titleInput = within(screen.getByRole("dialog")).getAllByRole( expect(
"textbox", await screen.findByPlaceholderText("Policy name"),
)[0] as HTMLInputElement; ).toHaveValue("Signal");
fireEvent.change(titleInput, { 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" }, target: { value: "Custom Signal header" },
}); });
fireEvent.click( fireEvent.click(screen.getByRole("button", { name: "Next" }));
within(screen.getByRole("dialog")).getByRole("button", { name: "Save" }), fireEvent.click(screen.getByRole("button", { name: "Next" }));
); fireEvent.click(screen.getByRole("button", { name: "Finalize" }));
await waitFor(() => { await waitFor(() => {
expect(latest.customMethodCardMetaById?.signal?.label).toBe( 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(
<ScreenWithStateProbe
onState={(s) => {
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<string, string> = {};
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/);
});
}); });
@@ -13,6 +13,12 @@ describe("CoreValuesSelectScreen", () => {
fireEvent.click(await screen.findByRole("button", { name: "Discard" })); 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 () => { it("opens core value detail modal when a preset chip is clicked", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility")); fireEvent.click(screen.getByText("Accessibility"));
@@ -20,20 +26,36 @@ describe("CoreValuesSelectScreen", () => {
expect( expect(
within(dialog).getByRole("button", { name: "Add Value" }), within(dialog).getByRole("button", { name: "Add Value" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(
screen.queryByRole("menuitem", { name: "Customize" }),
).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
expect( 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(); ).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(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility")); fireEvent.click(screen.getByText("Accessibility"));
await screen.findByRole("dialog"); await screen.findByRole("dialog");
fireEvent.keyDown(document, { key: "Escape" }); 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(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
await editMeaningInOpenDialog("Changed meaning");
fireEvent.keyDown(document, { key: "Escape" });
expect( expect(
await screen.findByRole("button", { name: "Keep editing" }), await screen.findByRole("button", { name: "Keep editing" }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -46,7 +68,7 @@ describe("CoreValuesSelectScreen", () => {
it("keeps the pending value modal open when Keep editing is chosen", async () => { it("keeps the pending value modal open when Keep editing is chosen", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility")); fireEvent.click(screen.getByText("Accessibility"));
await screen.findByRole("dialog"); await editMeaningInOpenDialog("Changed meaning");
fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "Escape" });
fireEvent.click(await screen.findByRole("button", { name: "Keep editing" })); fireEvent.click(await screen.findByRole("button", { name: "Keep editing" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
@@ -78,6 +100,162 @@ describe("CoreValuesSelectScreen", () => {
} }
}); });
it("shows Remove only after the value has been added", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
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(<CoreValuesSelectScreen />);
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(<CoreValuesSelectScreen />);
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(<CoreValuesSelectScreen />);
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(<CoreValuesSelectScreen />);
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<string, string> = {};
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` // The "Add value" → custom-chip → modal flow uses a `customPending`
// session: dismissing the modal must drop the brand-new chip entirely // session: dismissing the modal must drop the brand-new chip entirely
// (not just unselect it), because the user never confirmed it via // (not just unselect it), because the user never confirmed it via
@@ -118,7 +296,6 @@ describe("CoreValuesSelectScreen", () => {
expect(countCustomChips(CUSTOM_LABEL)).toBe(1); expect(countCustomChips(CUSTOM_LABEL)).toBe(1);
fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "Escape" });
fireEvent.click(await screen.findByRole("button", { name: "Discard" }));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
}); });
@@ -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<string, string> = {};
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(
<CustomMethodCardWizardBlocksList
blocks={blocks}
fieldTypeLabels={fieldTypeLabels}
dragHandleAriaLabel="Drag to reorder this field"
listLabel="Fields"
onBlocksReorder={onBlocksReorder}
onEditBlock={() => {}}
/>,
);
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(
<CustomMethodCardWizardBlocksList
blocks={blocks}
fieldTypeLabels={fieldTypeLabels}
dragHandleAriaLabel="Drag to reorder this field"
listLabel="Fields"
onBlocksReorder={() => {}}
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(
<CustomMethodCardWizardBlocksList
blocks={blocks}
fieldTypeLabels={fieldTypeLabels}
dragHandleAriaLabel="Drag to reorder this field"
listLabel="Fields"
onBlocksReorder={onBlocksReorder}
onEditBlock={() => {}}
/>,
);
const rows = screen.getAllByRole("listitem");
const dataTransfer = createDataTransfer();
fireEvent.dragStart(rows[0], { dataTransfer });
fireEvent.drop(rows[1], { dataTransfer });
expect(onBlocksReorder).not.toHaveBeenCalled();
});
});
+186 -71
View File
@@ -191,27 +191,7 @@ describe("FinalReviewScreen — prefilled selections", () => {
}); });
describe("FinalReviewScreen — chip detail modal", () => { describe("FinalReviewScreen — chip detail modal", () => {
async function enterMethodCustomizeFromDialog(dialog: HTMLElement) { it("opens the chip modal when a chip is clicked, matching the preset copy", async () => {
fireEvent.click(
within(dialog).getByRole("button", { name: /more options/i }),
);
const customize = await screen.findByRole("menuitem", {
name: /^customize$/i,
});
fireEvent.click(customize);
}
async function enterCoreValueCustomizeFromDialog(dialog: HTMLElement) {
fireEvent.click(
within(dialog).getByRole("button", { name: /more options/i }),
);
const customize = await screen.findByRole("menuitem", {
name: /^customize$/i,
});
fireEvent.click(customize);
}
it("opens the read-only detail modal when a chip is clicked, matching the preset copy", async () => {
render(<FinalReviewWithCustomizeSelections />); render(<FinalReviewWithCustomizeSelections />);
const signalChip = await screen.findByRole("button", { name: "Signal" }); const signalChip = await screen.findByRole("button", { name: "Signal" });
@@ -273,35 +253,58 @@ describe("FinalReviewScreen — chip detail modal", () => {
within(dialog).getByRole("button", { name: /more options/i }), within(dialog).getByRole("button", { name: /more options/i }),
); );
await waitFor(() => { await waitFor(() => {
expect(
screen.getByRole("menuitem", { name: /^customize$/i }),
).toBeInTheDocument();
});
expect( expect(
screen.getByRole("menuitem", { name: /^duplicate$/i }), screen.getByRole("menuitem", { name: /^duplicate$/i }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
expect(
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 <FinalReviewScreen />;
}
render(<CoreValuesHarness />);
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(<FinalReviewWithCustomizeSelections />); render(<FinalReviewWithCustomizeSelections />);
fireEvent.click(await screen.findByRole("button", { name: "Signal" })); fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
expect( const saveButton = within(dialog).getByRole("button", { name: "Save" });
within(dialog).queryByRole("button", { name: "Save" }), expect(saveButton).toBeDisabled();
).not.toBeInTheDocument();
const principleField = within(dialog).getByRole("textbox", { const principleField = within(dialog).getByRole("textbox", {
name: /core principle/i, name: /core principle/i,
}); });
expect(principleField).toBeDisabled(); expect(principleField).not.toBeDisabled();
await enterMethodCustomizeFromDialog(dialog);
expect(
within(dialog).getByRole("button", { name: "Save" }),
).toBeDisabled();
fireEvent.change(principleField, { target: { value: "Edited principle" } }); fireEvent.change(principleField, { target: { value: "Edited principle" } });
await waitFor(() => { await waitFor(() => {
@@ -339,8 +342,8 @@ describe("FinalReviewScreen — chip detail modal", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("opens the editable Save modal for a values chip after Customize", async () => { it("opens the editable Save modal for a values chip", async () => {
// Customize / plain custom-rule path: snapshot is set, sections is not. // Plain custom-rule path: snapshot is set, sections is not.
function CoreValuesHarness() { function CoreValuesHarness() {
const { replaceState } = useCreateFlow(); const { replaceState } = useCreateFlow();
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -359,10 +362,6 @@ describe("FinalReviewScreen — chip detail modal", () => {
await screen.findByRole("button", { name: "Accessibility" }), await screen.findByRole("button", { name: "Accessibility" }),
); );
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
expect(
within(dialog).queryByRole("button", { name: "Save" }),
).not.toBeInTheDocument();
await enterCoreValueCustomizeFromDialog(dialog);
expect( expect(
within(dialog).getByRole("button", { name: "Save" }), within(dialog).getByRole("button", { name: "Save" }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -371,7 +370,7 @@ describe("FinalReviewScreen — chip detail modal", () => {
).not.toBeInTheDocument(); ).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`: // Mirrors the post-fix payload from `handleUseTemplateWithoutChanges`:
// template Values section is stripped from `sections`, snapshot + // template Values section is stripped from `sections`, snapshot +
// selected ids are seeded so the chip carries an `overrideKey`. // 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" }), await screen.findByRole("button", { name: "Accessibility" }),
); );
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
expect(
within(dialog).queryByRole("button", { name: "Save" }),
).not.toBeInTheDocument();
await enterCoreValueCustomizeFromDialog(dialog);
expect( expect(
within(dialog).getByRole("button", { name: "Save" }), within(dialog).getByRole("button", { name: "Save" }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -418,21 +413,10 @@ describe("FinalReviewScreen — chip detail modal", () => {
* *
* 1. Save starts disabled (no edits yet nothing to persist). * 1. Save starts disabled (no edits yet nothing to persist).
* 2. Editing any field flips Save on; clicking it writes the typed * 2. Editing any field flips Save on; clicking it writes the typed
* `{group}MethodDetailsById[id]` entry into create-flow state and * `{group}MethodDetailsById[id]` entry into create-flow state.
* closes the modal.
* 3. Closing without Save discards every typed change. * 3. Closing without Save discards every typed change.
*/ */
describe("FinalReviewScreen — chip edit modal save semantics", () => { 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 = { const baseSelections: CreateFlowState = {
title: "Oak Park Commons", title: "Oak Park Commons",
selectedCommunicationMethodIds: ["signal"], selectedCommunicationMethodIds: ["signal"],
@@ -452,7 +436,6 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
fireEvent.click(await screen.findByRole("button", { name: "Signal" })); fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
await enterMethodCustomizeFromDialog(dialog);
const saveButton = within(dialog).getByRole("button", { name: "Save" }); const saveButton = within(dialog).getByRole("button", { name: "Save" });
expect(saveButton).toBeDisabled(); expect(saveButton).toBeDisabled();
const principleField = within(dialog).getByRole("textbox", { 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" })); fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
await enterMethodCustomizeFromDialog(dialog);
const principleField = within(dialog).getByRole("textbox", { const principleField = within(dialog).getByRole("textbox", {
name: /core principle/i, name: /core principle/i,
}); });
@@ -492,13 +474,6 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
}); });
fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(
within(screen.getByRole("dialog")).queryByRole("button", {
name: "Save",
}),
).not.toBeInTheDocument();
});
await waitFor(() => { await waitFor(() => {
expect( expect(
latest.communicationMethodDetailsById?.signal?.corePrinciple, latest.communicationMethodDetailsById?.signal?.corePrinciple,
@@ -519,7 +494,6 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
fireEvent.click(await screen.findByRole("button", { name: "Signal" })); fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
await enterMethodCustomizeFromDialog(dialog);
const principleField = within(dialog).getByRole("textbox", { const principleField = within(dialog).getByRole("textbox", {
name: /core principle/i, name: /core principle/i,
}); });
@@ -626,7 +600,6 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
await screen.findByRole("button", { name: "Custom Comm" }), await screen.findByRole("button", { name: "Custom Comm" }),
); );
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
await enterMethodCustomizeFromDialog(dialog);
expect( expect(
within(dialog).queryByText(/no custom fields yet/i), within(dialog).queryByText(/no custom fields yet/i),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@@ -670,7 +643,6 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
await screen.findByRole("button", { name: "Custom Comm" }), await screen.findByRole("button", { name: "Custom Comm" }),
); );
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
await enterMethodCustomizeFromDialog(dialog);
const notesField = within(dialog).getByRole("textbox", { name: /notes/i }); const notesField = within(dialog).getByRole("textbox", { name: /notes/i });
fireEvent.change(notesField, { target: { value: "Saved detail" } }); fireEvent.change(notesField, { target: { value: "Saved detail" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
@@ -685,6 +657,149 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
}); });
expect(screen.getByRole("dialog")).toBeInTheDocument(); expect(screen.getByRole("dialog")).toBeInTheDocument();
}); });
it("Customize opens the policy wizard prefilled with the chip title", async () => {
render(
<FinalReviewWithStateProbe
onState={() => {}}
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(
<FinalReviewWithStateProbe
onState={(s) => {
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(
<FinalReviewWithStateProbe
onState={(s) => {
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<string, string> = {};
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({ function FinalReviewEditPublishedWithStateProbe({
+15 -34
View File
@@ -40,7 +40,7 @@ describe("Create flow communication-methods page", () => {
expect(within(dialog).getByText("Add Platform")).toBeInTheDocument(); 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(); const user = userEvent.setup();
render(<CommunicationMethodsScreen />); render(<CommunicationMethodsScreen />);
@@ -59,6 +59,9 @@ describe("Create flow communication-methods page", () => {
expect( expect(
within(dialogAgain).queryByRole("button", { name: "Add Platform" }), within(dialogAgain).queryByRole("button", { name: "Add Platform" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(
within(dialogAgain).getByRole("button", { name: "Save" }),
).toBeInTheDocument();
await user.click(within(dialogAgain).getByRole("button", { name: "More options" })); await user.click(within(dialogAgain).getByRole("button", { name: "More options" }));
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
@@ -106,7 +109,7 @@ describe("Create flow communication-methods page", () => {
).not.toBeInTheDocument(); ).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(); const user = userEvent.setup();
render(<CommunicationMethodsScreen />); render(<CommunicationMethodsScreen />);
@@ -117,13 +120,10 @@ describe("Create flow communication-methods page", () => {
const dialog = screen.getByRole("dialog"); const dialog = screen.getByRole("dialog");
const textbox = within(dialog).getAllByRole("textbox")[0]; 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(within(dialog).getByRole("button", { name: "More options" }));
await user.click(screen.getByRole("menuitem", { name: "Customize" })); expect(screen.getByRole("menuitem", { name: "Customize" })).toBeInTheDocument();
expect(
within(screen.getByRole("dialog")).getAllByRole("textbox")[0],
).not.toBeDisabled();
}); });
test("renders without error", () => { test("renders without error", () => {
@@ -196,7 +196,7 @@ describe("Create flow communication-methods page", () => {
).toBeInTheDocument(); ).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 user = userEvent.setup();
const initial = { const initial = {
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID], selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
@@ -227,20 +227,11 @@ describe("Create flow communication-methods page", () => {
const textboxesBefore = within(dialog).getAllByRole("textbox"); const textboxesBefore = within(dialog).getAllByRole("textbox");
expect(textboxesBefore).toHaveLength(1); expect(textboxesBefore).toHaveLength(1);
const textarea = textboxesBefore[0]; const textarea = textboxesBefore[0];
expect(textarea).toBeDisabled(); expect(textarea).not.toBeDisabled();
expect(textarea).toHaveValue("Enter norms here"); 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 user = userEvent.setup();
const initial = { const initial = {
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID], selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
@@ -268,22 +259,14 @@ describe("Create flow communication-methods page", () => {
const dialog = screen.getByRole("dialog"); const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("Choose channels")).toBeInTheDocument(); expect(within(dialog).getByText("Choose channels")).toBeInTheDocument();
const alpha = within(dialog).getByRole("button", { name: /^Alpha$/ }); const alpha = within(dialog).getByRole("button", {
const beta = within(dialog).getByRole("button", { name: /^Beta$/ });
expect(alpha).toBeDisabled();
expect(beta).toBeDisabled();
await user.click(within(dialog).getByRole("button", { name: "More options" }));
await user.click(screen.getByRole("menuitem", { name: "Customize" }));
const alphaAfter = within(screen.getByRole("dialog")).getByRole("button", {
name: /Deselect Alpha/, name: /Deselect Alpha/,
}); });
const betaAfter = within(screen.getByRole("dialog")).getByRole("button", { const beta = within(dialog).getByRole("button", {
name: /Deselect Beta/, name: /Deselect Beta/,
}); });
expect(alphaAfter).not.toBeDisabled(); expect(alpha).not.toBeDisabled();
expect(betaAfter).not.toBeDisabled(); expect(beta).not.toBeDisabled();
}); });
test("editing custom policy field blocks updates draft state after Save", async () => { 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]); await user.click(policyTiles[0]);
const dialog = screen.getByRole("dialog"); 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.clear(textarea);
await user.type(textarea, "Updated norms"); await user.type(textarea, "Updated norms");
+4 -1
View File
@@ -181,7 +181,7 @@ describe("Create flow decision-approaches page", () => {
expect(screen.getByText("SELECTED")).toBeInTheDocument(); 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(); const user = userEvent.setup();
render(<DecisionApproachesScreen />); render(<DecisionApproachesScreen />);
@@ -204,6 +204,9 @@ describe("Create flow decision-approaches page", () => {
expect( expect(
within(dialogAgain).queryByRole("button", { name: "Add Approach" }), within(dialogAgain).queryByRole("button", { name: "Add Approach" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(
within(dialogAgain).getByRole("button", { name: "Save" }),
).toBeInTheDocument();
await user.click(within(dialogAgain).getByRole("button", { name: "More options" })); await user.click(within(dialogAgain).getByRole("button", { name: "More options" }));
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
@@ -214,4 +214,39 @@ describe("applyFinalReviewChipEditPatch", () => {
"1": { meaning: "m2", signals: "s2" }, "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,
});
});
}); });
+278
View File
@@ -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",
});
});
});
@@ -113,4 +113,16 @@ describe("usesWizardFieldBlocksModalBody", () => {
}), }),
).toBe(true); ).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);
});
}); });