Custom add and create flow polish
This commit is contained in:
@@ -52,12 +52,12 @@ import {
|
||||
} from "./utils/anonymousDraftStorage";
|
||||
import {
|
||||
createFlowStateFromPublishedRule,
|
||||
isPublishedRuleSelectionMissing,
|
||||
isPublishedRuleHydratePatchIncomplete,
|
||||
methodSectionsPinsFromPublishedHydratePatch,
|
||||
} from "../../../lib/create/publishedDocumentToCreateFlowState";
|
||||
import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets";
|
||||
import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||
import { deleteServerDraft } from "../../../lib/create/api";
|
||||
import { runCompletedStepExit } from "./utils/runCompletedStepExit";
|
||||
import messages from "../../../messages/en/index";
|
||||
import {
|
||||
CREATE_FLOW_FOOTER_BUTTON_CLASS,
|
||||
@@ -252,12 +252,11 @@ function CreateFlowLayoutContent({
|
||||
// For signed-in users we also DELETE the server draft so a future visit to
|
||||
// /create starts fresh instead of rehydrating yesterday's work.
|
||||
if (currentStep === "completed") {
|
||||
clearState();
|
||||
clearAnonymousCreateFlowStorage();
|
||||
if (sessionUser) {
|
||||
void deleteServerDraft();
|
||||
}
|
||||
router.push(CREATE_ROUTES.root);
|
||||
runCompletedStepExit({
|
||||
clearState,
|
||||
clearAnonymousCreateFlowStorage,
|
||||
router,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -335,7 +334,7 @@ function CreateFlowLayoutContent({
|
||||
titleOk &&
|
||||
editingId === last.id &&
|
||||
sectionsClear &&
|
||||
!isPublishedRuleSelectionMissing(state, patch)
|
||||
!isPublishedRuleHydratePatchIncomplete(state, patch)
|
||||
) {
|
||||
if (needsPinMerge) {
|
||||
updateState({
|
||||
@@ -362,6 +361,7 @@ function CreateFlowLayoutContent({
|
||||
state.title,
|
||||
state.methodSectionsPinCommitted,
|
||||
state.sections?.length,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface ApplicableScopeFieldProps {
|
||||
* Optional placeholder for the inline input. Defaults to `addLabel`.
|
||||
*/
|
||||
inputPlaceholder?: string;
|
||||
/** When true, scope chips and add affordance are non-interactive. */
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -46,6 +48,7 @@ function ApplicableScopeFieldComponent({
|
||||
onToggleScope,
|
||||
onAddScope,
|
||||
inputPlaceholder,
|
||||
readOnly = false,
|
||||
className = "",
|
||||
}: ApplicableScopeFieldProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
@@ -78,13 +81,13 @@ function ApplicableScopeFieldComponent({
|
||||
state={isSelected ? "selected" : "disabled"}
|
||||
palette="default"
|
||||
size="s"
|
||||
disabled={false}
|
||||
onClick={() => onToggleScope(scope)}
|
||||
disabled={readOnly}
|
||||
onClick={() => !readOnly && onToggleScope(scope)}
|
||||
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isAdding ? (
|
||||
{readOnly ? null : isAdding ? (
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
|
||||
@@ -14,8 +14,8 @@ import { memo, useCallback, useRef, useState } from "react";
|
||||
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
|
||||
import Chip from "../../../components/controls/Chip";
|
||||
import IncrementerBlock from "../../../components/controls/IncrementerBlock";
|
||||
import InlineTextButton from "../../../components/buttons/InlineTextButton";
|
||||
import Upload from "../../../components/controls/Upload";
|
||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||
import ApplicableScopeField from "./ApplicableScopeField";
|
||||
import InputLabel from "../../../components/type/InputLabel";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
|
||||
@@ -44,7 +44,9 @@ function CustomMethodCardUploadBlockRow({
|
||||
patch,
|
||||
uploadFileInputAriaLabel,
|
||||
uploadHint,
|
||||
clearFileLabel,
|
||||
clearPendingUploadAriaLabel,
|
||||
clearPendingUploadTooltip,
|
||||
uploadPreviewImageAlt,
|
||||
noFileChosen,
|
||||
}: {
|
||||
block: Extract<CustomMethodCardFieldBlock, { kind: "upload" }>;
|
||||
@@ -52,7 +54,9 @@ function CustomMethodCardUploadBlockRow({
|
||||
patch: (_next: CustomMethodCardFieldBlock[]) => void;
|
||||
uploadFileInputAriaLabel: string;
|
||||
uploadHint: string;
|
||||
clearFileLabel: string;
|
||||
clearPendingUploadAriaLabel: string;
|
||||
clearPendingUploadTooltip: string;
|
||||
uploadPreviewImageAlt: string;
|
||||
noFileChosen: string;
|
||||
}) {
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -60,8 +64,17 @@ function CustomMethodCardUploadBlockRow({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const displayName = block.fileName?.trim() ? block.fileName : noFileChosen;
|
||||
const hasAsset = Boolean(block.assetUrl?.trim());
|
||||
const previewAlt = block.fileName?.trim() || block.blockTitle || noFileChosen;
|
||||
const assetUrlTrimmed = block.assetUrl?.trim() ?? "";
|
||||
const hasAsset = assetUrlTrimmed.length > 0;
|
||||
|
||||
const clearUpload = () =>
|
||||
patch(
|
||||
mapBlockById(blocks, block.id, (b) =>
|
||||
b.kind === "upload"
|
||||
? { ...b, fileName: undefined, assetUrl: undefined }
|
||||
: b,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -76,14 +89,6 @@ function CustomMethodCardUploadBlockRow({
|
||||
{displayName}
|
||||
</p>
|
||||
) : null}
|
||||
{hasAsset ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL
|
||||
<img
|
||||
src={block.assetUrl!.trim()}
|
||||
alt={previewAlt}
|
||||
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
@@ -123,13 +128,41 @@ function CustomMethodCardUploadBlockRow({
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
<Upload
|
||||
active={!busy}
|
||||
hintText={busy ? tUpload("uploading") : uploadHint}
|
||||
onClick={() => {
|
||||
if (!busy) uploadInputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
{hasAsset ? (
|
||||
<div className="relative inline-block max-w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearUpload}
|
||||
className="absolute right-[8px] top-[8px] z-[1] flex h-[32px] w-[32px] cursor-pointer items-center justify-center rounded-full bg-[var(--color-surface-default-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
|
||||
aria-label={clearPendingUploadAriaLabel}
|
||||
title={clearPendingUploadTooltip}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
|
||||
<img
|
||||
src={getAssetPath("assets/Icon_Close.svg")}
|
||||
alt=""
|
||||
className="h-[16px] w-[16px]"
|
||||
style={{
|
||||
filter: "brightness(0) invert(1)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL */}
|
||||
<img
|
||||
src={assetUrlTrimmed}
|
||||
alt={uploadPreviewImageAlt}
|
||||
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
active={!busy}
|
||||
hintText={busy ? tUpload("uploading") : uploadHint}
|
||||
onClick={() => {
|
||||
if (!busy) uploadInputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{errorMessage ? (
|
||||
<p
|
||||
className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
|
||||
@@ -138,21 +171,6 @@ function CustomMethodCardUploadBlockRow({
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{block.fileName?.trim() || block.assetUrl?.trim() ? (
|
||||
<InlineTextButton
|
||||
onClick={() =>
|
||||
patch(
|
||||
mapBlockById(blocks, block.id, (b) =>
|
||||
b.kind === "upload"
|
||||
? { ...b, fileName: undefined, assetUrl: undefined }
|
||||
: b,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{clearFileLabel}
|
||||
</InlineTextButton>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -297,7 +315,13 @@ function CustomMethodCardFieldBlocksSummaryComponent({
|
||||
patch={patch}
|
||||
uploadFileInputAriaLabel={fm.upload.uploadFileInputAriaLabel}
|
||||
uploadHint={fm.upload.uploadHint}
|
||||
clearFileLabel={em.clearFileLabel}
|
||||
clearPendingUploadAriaLabel={
|
||||
fm.upload.clearPendingUploadAriaLabel
|
||||
}
|
||||
clearPendingUploadTooltip={
|
||||
fm.upload.clearPendingUploadTooltip
|
||||
}
|
||||
uploadPreviewImageAlt={fm.upload.uploadPreviewImageAlt}
|
||||
noFileChosen={noFileChosen}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import ContentLockup from "../../../components/type/ContentLockup";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import type { CreateFlowState } from "../types";
|
||||
import CustomMethodCardFieldBlocksSummary from "./CustomMethodCardFieldBlocksSummary";
|
||||
@@ -12,12 +14,22 @@ export default function CustomMethodCardModalBody({
|
||||
/** When set, used instead of `blocksById[cardId]` (e.g. final-review draft). */
|
||||
blocksOverride,
|
||||
onFieldBlocksChange,
|
||||
policyMeta,
|
||||
/**
|
||||
* When false, omit {@link ContentLockup} for title/description (Customize mode:
|
||||
* {@link MethodCardCustomizeModalHeader} already edits them). Summary line still shows.
|
||||
* @default true
|
||||
*/
|
||||
showPolicyContentLockupWhenNoBlocks = true,
|
||||
}: {
|
||||
cardId: string;
|
||||
blocksById: CreateFlowState["customMethodCardFieldBlocksById"];
|
||||
blocksOverride?: CustomMethodCardFieldBlock[] | null;
|
||||
onFieldBlocksChange?: (_blocks: CustomMethodCardFieldBlock[]) => void;
|
||||
policyMeta?: { label: string; supportText: string };
|
||||
showPolicyContentLockupWhenNoBlocks?: boolean;
|
||||
}) {
|
||||
const m = useMessages();
|
||||
const blocks = blocksOverride ?? blocksById?.[cardId];
|
||||
if (blocks && blocks.length > 0) {
|
||||
return (
|
||||
@@ -27,5 +39,30 @@ export default function CustomMethodCardModalBody({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const label = policyMeta?.label?.trim() ?? "";
|
||||
const support = policyMeta?.supportText?.trim() ?? "";
|
||||
if (label.length > 0 || support.length > 0) {
|
||||
const noFieldsHint = m.create.customRule.customMethodCardWizard.editModal
|
||||
.noCustomFieldsYet;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{showPolicyContentLockupWhenNoBlocks ? (
|
||||
<ContentLockup
|
||||
title={label.length > 0 ? label : undefined}
|
||||
description={support.length > 0 ? support : undefined}
|
||||
variant="modal"
|
||||
alignment="left"
|
||||
/>
|
||||
) : null}
|
||||
{noFieldsHint.trim().length > 0 ? (
|
||||
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m,15px)] leading-[var(--line-height-body-m,22px)] text-[var(--color-content-default-secondary)]">
|
||||
{noFieldsHint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <CustomMethodCardPresetEditPlaceholder />;
|
||||
}
|
||||
|
||||
+7
@@ -8,6 +8,7 @@ import {
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
|
||||
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
|
||||
import { CustomMethodCardWizardView } from "./CustomMethodCardWizard.view";
|
||||
import type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types";
|
||||
|
||||
@@ -21,6 +22,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
const t = useTranslation("common");
|
||||
const tUpload = useTranslation("create.upload");
|
||||
const w = m.create.customRule.customMethodCardWizard;
|
||||
const menuCopy = m.create.customRule.modalKebabMenu;
|
||||
|
||||
const copy = useMemo(
|
||||
() => ({
|
||||
@@ -228,6 +230,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
dismiss();
|
||||
}, [dismiss, fieldTypeModal]);
|
||||
|
||||
const kebabMenuItems = useMemo<ModalHeaderMenuItem[]>(() => [], []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (fieldTypeModal) {
|
||||
setFieldTypeModal(null);
|
||||
@@ -416,6 +420,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
stepper={!fieldTypeModal}
|
||||
draftFieldBlocks={draftFieldBlocks}
|
||||
onDraftFieldBlocksReorder={setDraftFieldBlocks}
|
||||
kebabMoreOptionsAriaLabel={menuCopy.triggerAriaLabel}
|
||||
kebabMenuAriaLabel={menuCopy.menuAriaLabel}
|
||||
kebabMenuItems={kebabMenuItems}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RefObject } from "react";
|
||||
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||
import type { ModalHeaderMenuItem } from "../../../../components/modals/ModalHeader/ModalHeader.types";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
|
||||
export interface CustomMethodCardWizardFieldBodiesCopy {
|
||||
@@ -141,4 +142,7 @@ export interface CustomMethodCardWizardViewProps {
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
stepper: boolean;
|
||||
kebabMoreOptionsAriaLabel: string;
|
||||
kebabMenuAriaLabel: string;
|
||||
kebabMenuItems: ModalHeaderMenuItem[];
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ function CustomMethodCardWizardViewComponent({
|
||||
stepper,
|
||||
draftFieldBlocks,
|
||||
onDraftFieldBlocksReorder,
|
||||
kebabMoreOptionsAriaLabel,
|
||||
kebabMenuAriaLabel,
|
||||
kebabMenuItems,
|
||||
}: CustomMethodCardWizardViewProps) {
|
||||
return (
|
||||
<Create
|
||||
@@ -52,6 +55,9 @@ function CustomMethodCardWizardViewComponent({
|
||||
totalSteps={3}
|
||||
stepper={stepper}
|
||||
backdropVariant="blurredYellow"
|
||||
kebabTriggerAriaLabel={kebabMoreOptionsAriaLabel}
|
||||
kebabMenuAriaLabel={kebabMenuAriaLabel}
|
||||
kebabMenuItems={kebabMenuItems}
|
||||
>
|
||||
{fieldTypeModal ? (
|
||||
<CustomMethodCardWizardFieldBodiesView
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Editable policy title + description for method-card Create modals in Customize mode.
|
||||
* View mode continues to use {@link ContentLockup} via the `Create` modal defaults.
|
||||
*/
|
||||
|
||||
import TextInput from "../../../components/controls/TextInput";
|
||||
import ModalTextAreaField from "./ModalTextAreaField";
|
||||
|
||||
export interface MethodCardCustomizeModalHeaderProps {
|
||||
titleLabel: string;
|
||||
descriptionLabel: string;
|
||||
titleValue: string;
|
||||
descriptionValue: string;
|
||||
onTitleChange: (_value: string) => void;
|
||||
onDescriptionChange: (_value: string) => void;
|
||||
/** @default 3 */
|
||||
descriptionRows?: number;
|
||||
/** When false, only the policy title row is rendered (core values rename). */
|
||||
showDescription?: boolean;
|
||||
}
|
||||
|
||||
export default function MethodCardCustomizeModalHeader({
|
||||
titleLabel,
|
||||
descriptionLabel,
|
||||
titleValue,
|
||||
descriptionValue,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
descriptionRows = 3,
|
||||
showDescription = true,
|
||||
}: MethodCardCustomizeModalHeaderProps) {
|
||||
return (
|
||||
<div className="bg-[var(--color-surface-default-primary)] flex shrink-0 flex-col gap-4 px-[24px] py-[12px]">
|
||||
<TextInput
|
||||
label={titleLabel}
|
||||
value={titleValue}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
inputSize="medium"
|
||||
/>
|
||||
{showDescription ? (
|
||||
<ModalTextAreaField
|
||||
label={descriptionLabel}
|
||||
value={descriptionValue}
|
||||
onChange={onDescriptionChange}
|
||||
rows={descriptionRows}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ModalHeaderMenuItem } from "../../../components/modals/ModalHeader/ModalHeader.types";
|
||||
|
||||
export interface CustomRuleModalKebabMenuCopy {
|
||||
items: {
|
||||
customize: string;
|
||||
duplicate: string;
|
||||
remove: string;
|
||||
};
|
||||
saveEdits: string;
|
||||
}
|
||||
|
||||
export interface CustomRuleModalKebabHandlers {
|
||||
showCustomize?: boolean;
|
||||
onCustomize?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
showRemove?: boolean;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export function buildCustomRuleModalKebabMenu(
|
||||
copy: CustomRuleModalKebabMenuCopy,
|
||||
handlers: CustomRuleModalKebabHandlers,
|
||||
): ModalHeaderMenuItem[] {
|
||||
const items: ModalHeaderMenuItem[] = [];
|
||||
if (handlers.showCustomize && handlers.onCustomize) {
|
||||
items.push({
|
||||
id: "customize",
|
||||
label: copy.items.customize,
|
||||
leadingIcon: "custom",
|
||||
onClick: handlers.onCustomize,
|
||||
});
|
||||
}
|
||||
if (handlers.onDuplicate) {
|
||||
items.push({
|
||||
id: "duplicate",
|
||||
label: copy.items.duplicate,
|
||||
leadingIcon: "content_copy",
|
||||
onClick: handlers.onDuplicate,
|
||||
});
|
||||
}
|
||||
if (handlers.showRemove && handlers.onRemove) {
|
||||
items.push({
|
||||
id: "remove",
|
||||
label: copy.items.remove,
|
||||
leadingIcon: "warning",
|
||||
variant: "destructive",
|
||||
onClick: handlers.onRemove,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import type { CommunicationMethodDetailEntry } from "../../types";
|
||||
export interface CommunicationMethodEditFieldsProps {
|
||||
value: CommunicationMethodDetailEntry;
|
||||
onChange: (_next: CommunicationMethodDetailEntry) => void;
|
||||
/** When true, fields are not editable (view mode). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const FIELDS: ReadonlyArray<keyof CommunicationMethodDetailEntry> = [
|
||||
@@ -26,6 +28,7 @@ const FIELDS: ReadonlyArray<keyof CommunicationMethodDetailEntry> = [
|
||||
function CommunicationMethodEditFieldsComponent({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
}: CommunicationMethodEditFieldsProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.communication;
|
||||
@@ -49,6 +52,7 @@ function CommunicationMethodEditFieldsComponent({
|
||||
rows={6}
|
||||
value={value[field]}
|
||||
onChange={(v) => patch(field, v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -37,11 +37,13 @@ function conflictDetailWithScopeTextarea(
|
||||
export interface ConflictManagementEditFieldsProps {
|
||||
value: ConflictManagementDetailEntry;
|
||||
onChange: (_next: ConflictManagementDetailEntry) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function ConflictManagementEditFieldsComponent({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
}: ConflictManagementEditFieldsProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.conflictManagement;
|
||||
@@ -62,6 +64,7 @@ function ConflictManagementEditFieldsComponent({
|
||||
label={t.sectionHeadings.corePrinciple}
|
||||
value={value.corePrinciple}
|
||||
onChange={(v) => patch("corePrinciple", v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.applicableScope}
|
||||
@@ -69,16 +72,19 @@ function ConflictManagementEditFieldsComponent({
|
||||
placeholder={t.applicableScopePlaceholder}
|
||||
onChange={(v) => onChange(conflictDetailWithScopeTextarea(value, v))}
|
||||
rows={4}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.processProtocol}
|
||||
value={value.processProtocol}
|
||||
onChange={(v) => patch("processProtocol", v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.restorationFallbacks}
|
||||
value={value.restorationFallbacks}
|
||||
onChange={(v) => patch("restorationFallbacks", v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,11 +15,14 @@ import type { CoreValueDetailEntry } from "../../types";
|
||||
export interface CoreValueEditFieldsProps {
|
||||
value: CoreValueDetailEntry;
|
||||
onChange: (_next: CoreValueDetailEntry) => void;
|
||||
/** View mode until the user taps **Customize**. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function CoreValueEditFieldsComponent({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
}: CoreValueEditFieldsProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.coreValues.detailModal;
|
||||
@@ -41,12 +44,14 @@ function CoreValueEditFieldsComponent({
|
||||
value={value.meaning}
|
||||
onChange={(v) => patch("meaning", v)}
|
||||
rows={4}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={t.signalsLabel}
|
||||
value={value.signals}
|
||||
onChange={(v) => patch("signals", v)}
|
||||
rows={4}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { DecisionApproachDetailEntry } from "../../types";
|
||||
export interface DecisionApproachEditFieldsProps {
|
||||
value: DecisionApproachDetailEntry;
|
||||
onChange: (_next: DecisionApproachDetailEntry) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const CONSENSUS_LEVEL_MIN = 0;
|
||||
@@ -26,6 +27,7 @@ const CONSENSUS_LEVEL_STEP = 5;
|
||||
function DecisionApproachEditFieldsComponent({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
}: DecisionApproachEditFieldsProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.decisionApproaches;
|
||||
@@ -46,12 +48,14 @@ function DecisionApproachEditFieldsComponent({
|
||||
label={t.sectionHeadings.corePrinciple}
|
||||
value={value.corePrinciple}
|
||||
onChange={(v) => patch("corePrinciple", v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<ApplicableScopeField
|
||||
label={t.sectionHeadings.applicableScope}
|
||||
addLabel={t.scopeAddButtonLabel}
|
||||
scopes={value.applicableScope}
|
||||
selectedScopes={value.selectedApplicableScope}
|
||||
readOnly={readOnly}
|
||||
onToggleScope={(scope) =>
|
||||
patch(
|
||||
"selectedApplicableScope",
|
||||
@@ -68,6 +72,7 @@ function DecisionApproachEditFieldsComponent({
|
||||
label={t.sectionHeadings.stepByStepInstructions}
|
||||
value={value.stepByStepInstructions}
|
||||
onChange={(v) => patch("stepByStepInstructions", v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<IncrementerBlock
|
||||
label={t.sectionHeadings.consensusLevel}
|
||||
@@ -79,11 +84,13 @@ function DecisionApproachEditFieldsComponent({
|
||||
formatValue={(v) => `${v}%`}
|
||||
decrementAriaLabel="Decrease consensus level"
|
||||
incrementAriaLabel="Increase consensus level"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<ModalTextAreaField
|
||||
label={t.sectionHeadings.objectionsDeadlocks}
|
||||
value={value.objectionsDeadlocks}
|
||||
onChange={(v) => patch("objectionsDeadlocks", v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { MembershipMethodDetailEntry } from "../../types";
|
||||
export interface MembershipMethodEditFieldsProps {
|
||||
value: MembershipMethodDetailEntry;
|
||||
onChange: (_next: MembershipMethodDetailEntry) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const FIELDS: ReadonlyArray<keyof MembershipMethodDetailEntry> = [
|
||||
@@ -26,6 +27,7 @@ const FIELDS: ReadonlyArray<keyof MembershipMethodDetailEntry> = [
|
||||
function MembershipMethodEditFieldsComponent({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
}: MembershipMethodEditFieldsProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.membership;
|
||||
@@ -49,6 +51,7 @@ function MembershipMethodEditFieldsComponent({
|
||||
rows={6}
|
||||
value={value[field]}
|
||||
onChange={(v) => patch(field, v)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
import { useCallback } from "react";
|
||||
import type { CreateFlowState, CreateFlowStep } from "../types";
|
||||
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
|
||||
import {
|
||||
deleteServerDraft,
|
||||
saveDraftToServer,
|
||||
updatePublishedRule,
|
||||
} from "../../../../lib/create/api";
|
||||
import { saveDraftToServer, updatePublishedRule } from "../../../../lib/create/api";
|
||||
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
||||
import messages from "../../../../messages/en/index";
|
||||
|
||||
@@ -79,7 +75,6 @@ export function useCreateFlowExit({
|
||||
document,
|
||||
});
|
||||
setDraftSaveBannerMessage?.(null);
|
||||
void deleteServerDraft();
|
||||
} else {
|
||||
setDraftSaveBannerMessage?.(updateResult.error);
|
||||
return;
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
*
|
||||
* Card click opens the Figma create modal (node `20246-15829`) with three
|
||||
* editable sections rendered by {@link CommunicationMethodEditFields}. The primary
|
||||
* action is **Add Platform** for an unselected card or **Remove** when the card is
|
||||
* already selected — remove clears `selectedCommunicationMethodIds` and
|
||||
* `communicationMethodDetailsById` via {@link removeMethodCardFromFacetSelection}.
|
||||
* action is **Add Platform** for an unselected card; a selected card in view mode has
|
||||
* no footer primary — **Remove** is available from the kebab (same behavior as legacy
|
||||
* footer remove via {@link removeMethodCardFromFacetSelection}).
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
@@ -37,22 +37,52 @@ import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/custo
|
||||
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
||||
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
|
||||
import { communicationMethodFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId";
|
||||
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
|
||||
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
|
||||
import {
|
||||
cloneMethodCardBlocksForDuplicate,
|
||||
cloneMethodCardDetailsForDuplicate,
|
||||
duplicateMethodCardTitle,
|
||||
forkMethodCardFacetMapsForDuplicate,
|
||||
omitIdFromStringRecord,
|
||||
} from "../../../../../lib/create/duplicateMethodCardModalDraft";
|
||||
import type { CommunicationMethodDetailEntry } from "../../types";
|
||||
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||
import { useCustomMethodCardFieldBlocksChange } from "../../hooks/useCustomMethodCardFieldBlocksChange";
|
||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
||||
|
||||
export function CommunicationMethodsScreen() {
|
||||
const m = useMessages();
|
||||
const comm = m.create.customRule.communication;
|
||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||
useCreateFlow();
|
||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||
const customizeSnapshotRef = useRef<
|
||||
MethodCardCustomizeSnapshot<CommunicationMethodDetailEntry> | null
|
||||
>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
const [pendingDraft, setPendingDraft] =
|
||||
useState<CommunicationMethodDetailEntry | null>(null);
|
||||
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
||||
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||
CustomMethodCardFieldBlock[] | null
|
||||
>(null);
|
||||
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
||||
useState<MethodCardHeaderDraft | null>(null);
|
||||
|
||||
const selectedIds = state.selectedCommunicationMethodIds ?? [];
|
||||
|
||||
@@ -97,24 +127,6 @@ export function CommunicationMethodsScreen() {
|
||||
</>
|
||||
);
|
||||
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
const alreadySelected = selectedIds.includes(pendingCardId);
|
||||
return {
|
||||
title: method?.label ?? comm.confirmModal.title,
|
||||
description: method?.supportText ?? comm.confirmModal.description,
|
||||
nextButtonText: alreadySelected
|
||||
? comm.removePlatform.nextButtonText
|
||||
: comm.addPlatform.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: comm.confirmModal.title,
|
||||
description: comm.confirmModal.description,
|
||||
nextButtonText: comm.confirmModal.nextButtonText,
|
||||
};
|
||||
|
||||
const seedDraft = useCallback(
|
||||
(id: string): CommunicationMethodDetailEntry => {
|
||||
const saved = state.communicationMethodDetailsById?.[id];
|
||||
@@ -129,6 +141,10 @@ export function CommunicationMethodsScreen() {
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
setPendingCardId(id);
|
||||
setPendingDraft(seedDraft(id));
|
||||
setCreateModalOpen(true);
|
||||
@@ -144,17 +160,396 @@ export function CommunicationMethodsScreen() {
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
|
||||
createModalOpen ? pendingCardId : null,
|
||||
);
|
||||
const customModalReadOnly =
|
||||
const isSelectedCardModal =
|
||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||
const fieldsLocked = !modalEditUnlocked;
|
||||
|
||||
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
|
||||
|
||||
const customFacetDetailsMatchPreset = useMemo(() => {
|
||||
if (!pendingCardId || !pendingDraft) return false;
|
||||
if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) {
|
||||
return false;
|
||||
}
|
||||
return communicationMethodFacetMatchesPreset(pendingDraft, pendingCardId);
|
||||
}, [
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const modalUsesWizardFieldBlocksBody = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
pendingCardId &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
}),
|
||||
),
|
||||
[
|
||||
customFacetDetailsMatchPreset,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
const ephemeralId = pendingEphemeralDuplicateIdRef.current;
|
||||
if (ephemeralId) {
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
replaceState((prev) => ({
|
||||
...prev,
|
||||
customMethodCardMetaById: omitIdFromStringRecord(
|
||||
prev.customMethodCardMetaById,
|
||||
ephemeralId,
|
||||
),
|
||||
communicationMethodDetailsById: omitIdFromStringRecord(
|
||||
prev.communicationMethodDetailsById,
|
||||
ephemeralId,
|
||||
),
|
||||
customMethodCardFieldBlocksById: omitIdFromStringRecord(
|
||||
prev.customMethodCardFieldBlocksById,
|
||||
ephemeralId,
|
||||
),
|
||||
}));
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
setPendingDraft(null);
|
||||
}, []);
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
replaceState,
|
||||
]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
if (!modalEditUnlocked) {
|
||||
return;
|
||||
}
|
||||
const snap = customizeSnapshotRef.current;
|
||||
if (!snap) {
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
snap,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPendingDraft(structuredClone(snap.pendingDraft));
|
||||
setDraftFieldBlocks(null);
|
||||
setModalEditUnlocked(false);
|
||||
customizeSnapshotRef.current = null;
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
]);
|
||||
|
||||
const handleRemoveSelectedFromModal = useCallback(() => {
|
||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
updateState(
|
||||
removeMethodCardFromFacetSelection(
|
||||
state,
|
||||
"communication",
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
pendingCardId,
|
||||
selectedIds,
|
||||
state,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleCustomize = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (!pendingDraft || !pendingCardId) {
|
||||
return;
|
||||
}
|
||||
const persistedBlocks =
|
||||
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [];
|
||||
const initialFieldBlocks =
|
||||
persistedBlocks.length > 0
|
||||
? structuredClone(persistedBlocks)
|
||||
: isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? []
|
||||
: null;
|
||||
const method = methodById.get(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const headerDraft: MethodCardHeaderDraft = {
|
||||
title: meta?.label ?? method?.label ?? comm.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
comm.confirmModal.description,
|
||||
};
|
||||
setCustomizeHeaderDraft(headerDraft);
|
||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||
pendingDraft,
|
||||
initialFieldBlocks,
|
||||
headerDraft,
|
||||
);
|
||||
setDraftFieldBlocks(initialFieldBlocks);
|
||||
setModalEditUnlocked(true);
|
||||
}, [
|
||||
comm.confirmModal.description,
|
||||
comm.confirmModal.title,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const handleDuplicateCustomCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const meta = state.customMethodCardMetaById![pendingCardId]!;
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.communicationMethodDetailsById?.[pendingCardId],
|
||||
() => communicationPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.communicationMethodDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(meta.label, suffix),
|
||||
supportText: meta.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
communicationMethodDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
markCreateFlowInteraction,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
state.communicationMethodDetailsById,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleDuplicatePrefabCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const method = methodById.get(pendingCardId);
|
||||
if (!method || !pendingDraft) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.communicationMethodDetailsById?.[pendingCardId],
|
||||
() => communicationPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.communicationMethodDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(method.label, suffix),
|
||||
supportText: method.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
communicationMethodDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
draftFieldBlocks,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.communicationMethodDetailsById,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const kebabMenuItems = useMemo(
|
||||
() =>
|
||||
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||
showCustomize: !modalEditUnlocked,
|
||||
onCustomize: handleCustomize,
|
||||
onDuplicate:
|
||||
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
||||
? undefined
|
||||
: isCustomMethodCardId(
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
)
|
||||
? handleDuplicateCustomCard
|
||||
: handleDuplicatePrefabCard,
|
||||
showRemove: isSelectedCardModal,
|
||||
onRemove: handleRemoveSelectedFromModal,
|
||||
}),
|
||||
[
|
||||
handleCustomize,
|
||||
handleDuplicateCustomCard,
|
||||
handleDuplicatePrefabCard,
|
||||
handleRemoveSelectedFromModal,
|
||||
isSelectedCardModal,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu,
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
state.editingPublishedRuleId,
|
||||
],
|
||||
);
|
||||
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const saveLabel = modalKebabMenu.saveEdits;
|
||||
return {
|
||||
title: meta?.label ?? method?.label ?? comm.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
comm.confirmModal.description,
|
||||
nextButtonText: modalEditUnlocked
|
||||
? saveLabel
|
||||
: comm.addPlatform.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: comm.confirmModal.title,
|
||||
description: comm.confirmModal.description,
|
||||
nextButtonText: comm.confirmModal.nextButtonText,
|
||||
};
|
||||
|
||||
const handleCloseAddWizard = useCallback(() => {
|
||||
setAddCustomWizardOpen(false);
|
||||
@@ -207,17 +602,98 @@ export function CommunicationMethodsScreen() {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
|
||||
if (selectedIds.includes(pendingCardId)) {
|
||||
updateState(
|
||||
removeMethodCardFromFacetSelection(
|
||||
state,
|
||||
"communication",
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
communicationMethodDetailsById: {
|
||||
...(state.communicationMethodDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
communicationMethodDetailsById: {
|
||||
...(state.communicationMethodDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingDraft) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
@@ -232,10 +708,14 @@ export function CommunicationMethodsScreen() {
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
selectedIds,
|
||||
@@ -280,31 +760,62 @@ export function CommunicationMethodsScreen() {
|
||||
<Create
|
||||
isOpen={createModalOpen}
|
||||
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}
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={false}
|
||||
showBackButton={modalEditUnlocked}
|
||||
onBack={handleCancelCustomize}
|
||||
backButtonText={modalKebabMenu.cancelCustomize}
|
||||
showNextButton={showMethodModalPrimary}
|
||||
backdropVariant="blurredYellow"
|
||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||
kebabMenuItems={kebabMenuItems}
|
||||
>
|
||||
{pendingCardId && pendingDraft ? (
|
||||
isCustomMethodCardId(
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
) ? (
|
||||
modalUsesWizardFieldBlocksBody ? (
|
||||
<CustomMethodCardModalBody
|
||||
key={pendingCardId}
|
||||
cardId={pendingCardId}
|
||||
blocksById={state.customMethodCardFieldBlocksById}
|
||||
blocksOverride={
|
||||
modalEditUnlocked && draftFieldBlocks !== null
|
||||
? draftFieldBlocks
|
||||
: undefined
|
||||
}
|
||||
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
||||
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
|
||||
onFieldBlocksChange={
|
||||
customModalReadOnly ? undefined : onCustomFieldBlocksChange
|
||||
fieldsLocked
|
||||
? undefined
|
||||
: (next) => setDraftFieldBlocks(next)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<CommunicationMethodEditFields
|
||||
key={pendingCardId}
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
readOnly={fieldsLocked}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* any user edits as a `conflictManagementDetailsById[id]` override.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
@@ -34,22 +34,52 @@ import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/custo
|
||||
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
||||
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
|
||||
import { conflictManagementFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId";
|
||||
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
|
||||
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
|
||||
import {
|
||||
cloneMethodCardBlocksForDuplicate,
|
||||
cloneMethodCardDetailsForDuplicate,
|
||||
duplicateMethodCardTitle,
|
||||
forkMethodCardFacetMapsForDuplicate,
|
||||
omitIdFromStringRecord,
|
||||
} from "../../../../../lib/create/duplicateMethodCardModalDraft";
|
||||
import type { ConflictManagementDetailEntry } from "../../types";
|
||||
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||
import { useCustomMethodCardFieldBlocksChange } from "../../hooks/useCustomMethodCardFieldBlocksChange";
|
||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
||||
|
||||
export function ConflictManagementScreen() {
|
||||
const m = useMessages();
|
||||
const cm = m.create.customRule.conflictManagement;
|
||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||
useCreateFlow();
|
||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||
const customizeSnapshotRef = useRef<
|
||||
MethodCardCustomizeSnapshot<ConflictManagementDetailEntry> | null
|
||||
>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
const [pendingDraft, setPendingDraft] =
|
||||
useState<ConflictManagementDetailEntry | null>(null);
|
||||
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
||||
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||
CustomMethodCardFieldBlock[] | null
|
||||
>(null);
|
||||
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
||||
useState<MethodCardHeaderDraft | null>(null);
|
||||
|
||||
const selectedIds = state.selectedConflictManagementIds ?? [];
|
||||
|
||||
@@ -94,24 +124,6 @@ export function ConflictManagementScreen() {
|
||||
</>
|
||||
);
|
||||
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
const alreadySelected = selectedIds.includes(pendingCardId);
|
||||
return {
|
||||
title: method?.label ?? cm.confirmModal.title,
|
||||
description: method?.supportText ?? cm.confirmModal.description,
|
||||
nextButtonText: alreadySelected
|
||||
? cm.removeApproach.nextButtonText
|
||||
: cm.addApproach.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: cm.confirmModal.title,
|
||||
description: cm.confirmModal.description,
|
||||
nextButtonText: cm.confirmModal.nextButtonText,
|
||||
};
|
||||
|
||||
const seedDraft = useCallback(
|
||||
(id: string): ConflictManagementDetailEntry => {
|
||||
const saved = state.conflictManagementDetailsById?.[id];
|
||||
@@ -130,6 +142,10 @@ export function ConflictManagementScreen() {
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
setPendingCardId(id);
|
||||
setPendingDraft(seedDraft(id));
|
||||
setCreateModalOpen(true);
|
||||
@@ -145,17 +161,394 @@ export function ConflictManagementScreen() {
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
|
||||
createModalOpen ? pendingCardId : null,
|
||||
);
|
||||
const customModalReadOnly =
|
||||
const isSelectedCardModal =
|
||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||
const fieldsLocked = !modalEditUnlocked;
|
||||
|
||||
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
|
||||
|
||||
const customFacetDetailsMatchPreset = useMemo(() => {
|
||||
if (!pendingCardId || !pendingDraft) return false;
|
||||
if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) {
|
||||
return false;
|
||||
}
|
||||
return conflictManagementFacetMatchesPreset(pendingDraft, pendingCardId);
|
||||
}, [
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const modalUsesWizardFieldBlocksBody = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
pendingCardId &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
}),
|
||||
),
|
||||
[
|
||||
customFacetDetailsMatchPreset,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
const ephemeralId = pendingEphemeralDuplicateIdRef.current;
|
||||
if (ephemeralId) {
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
replaceState((prev) => ({
|
||||
...prev,
|
||||
customMethodCardMetaById: omitIdFromStringRecord(
|
||||
prev.customMethodCardMetaById,
|
||||
ephemeralId,
|
||||
),
|
||||
conflictManagementDetailsById: omitIdFromStringRecord(
|
||||
prev.conflictManagementDetailsById,
|
||||
ephemeralId,
|
||||
),
|
||||
customMethodCardFieldBlocksById: omitIdFromStringRecord(
|
||||
prev.customMethodCardFieldBlocksById,
|
||||
ephemeralId,
|
||||
),
|
||||
}));
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
setPendingDraft(null);
|
||||
}, []);
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
replaceState,
|
||||
]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
if (!modalEditUnlocked) {
|
||||
return;
|
||||
}
|
||||
const snap = customizeSnapshotRef.current;
|
||||
if (!snap) {
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
snap,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPendingDraft(structuredClone(snap.pendingDraft));
|
||||
setDraftFieldBlocks(null);
|
||||
setModalEditUnlocked(false);
|
||||
customizeSnapshotRef.current = null;
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
]);
|
||||
|
||||
const handleRemoveSelectedFromModal = useCallback(() => {
|
||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
updateState(
|
||||
removeMethodCardFromFacetSelection(
|
||||
state,
|
||||
"conflictManagement",
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
pendingCardId,
|
||||
selectedIds,
|
||||
state,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleCustomize = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (!pendingDraft || !pendingCardId) {
|
||||
return;
|
||||
}
|
||||
const initialFieldBlocks =
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? structuredClone(
|
||||
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
|
||||
)
|
||||
: null;
|
||||
const method = methodById.get(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const headerDraft: MethodCardHeaderDraft = {
|
||||
title: meta?.label ?? method?.label ?? cm.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
cm.confirmModal.description,
|
||||
};
|
||||
setCustomizeHeaderDraft(headerDraft);
|
||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||
pendingDraft,
|
||||
initialFieldBlocks,
|
||||
headerDraft,
|
||||
);
|
||||
setDraftFieldBlocks(initialFieldBlocks);
|
||||
setModalEditUnlocked(true);
|
||||
}, [
|
||||
cm.confirmModal.description,
|
||||
cm.confirmModal.title,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const handleDuplicateCustomCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const meta = state.customMethodCardMetaById![pendingCardId]!;
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.conflictManagementDetailsById?.[pendingCardId],
|
||||
() => conflictManagementPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.conflictManagementDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(meta.label, suffix),
|
||||
supportText: meta.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
conflictManagementDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
draftFieldBlocks,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.conflictManagementDetailsById,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleDuplicatePrefabCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const method = methodById.get(pendingCardId);
|
||||
if (!method || !pendingDraft) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.conflictManagementDetailsById?.[pendingCardId],
|
||||
() => conflictManagementPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.conflictManagementDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(method.label, suffix),
|
||||
supportText: method.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
conflictManagementDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
draftFieldBlocks,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.conflictManagementDetailsById,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const kebabMenuItems = useMemo(
|
||||
() =>
|
||||
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||
showCustomize: !modalEditUnlocked,
|
||||
onCustomize: handleCustomize,
|
||||
onDuplicate:
|
||||
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
||||
? undefined
|
||||
: isCustomMethodCardId(
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
)
|
||||
? handleDuplicateCustomCard
|
||||
: handleDuplicatePrefabCard,
|
||||
showRemove: isSelectedCardModal,
|
||||
onRemove: handleRemoveSelectedFromModal,
|
||||
}),
|
||||
[
|
||||
handleCustomize,
|
||||
handleDuplicateCustomCard,
|
||||
handleDuplicatePrefabCard,
|
||||
handleRemoveSelectedFromModal,
|
||||
isSelectedCardModal,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu,
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
state.editingPublishedRuleId,
|
||||
],
|
||||
);
|
||||
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const saveLabel = modalKebabMenu.saveEdits;
|
||||
return {
|
||||
title: meta?.label ?? method?.label ?? cm.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
cm.confirmModal.description,
|
||||
nextButtonText: modalEditUnlocked
|
||||
? saveLabel
|
||||
: cm.addApproach.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: cm.confirmModal.title,
|
||||
description: cm.confirmModal.description,
|
||||
nextButtonText: cm.confirmModal.nextButtonText,
|
||||
};
|
||||
|
||||
const handleCloseAddWizard = useCallback(() => {
|
||||
setAddCustomWizardOpen(false);
|
||||
@@ -208,17 +601,98 @@ export function ConflictManagementScreen() {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
|
||||
if (selectedIds.includes(pendingCardId)) {
|
||||
updateState(
|
||||
removeMethodCardFromFacetSelection(
|
||||
state,
|
||||
"conflictManagement",
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
conflictManagementDetailsById: {
|
||||
...(state.conflictManagementDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
conflictManagementDetailsById: {
|
||||
...(state.conflictManagementDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingDraft) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
@@ -233,10 +707,14 @@ export function ConflictManagementScreen() {
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
selectedIds,
|
||||
@@ -281,31 +759,62 @@ export function ConflictManagementScreen() {
|
||||
<Create
|
||||
isOpen={createModalOpen}
|
||||
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}
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={false}
|
||||
showBackButton={modalEditUnlocked}
|
||||
onBack={handleCancelCustomize}
|
||||
backButtonText={modalKebabMenu.cancelCustomize}
|
||||
showNextButton={showMethodModalPrimary}
|
||||
backdropVariant="blurredYellow"
|
||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||
kebabMenuItems={kebabMenuItems}
|
||||
>
|
||||
{pendingCardId && pendingDraft ? (
|
||||
isCustomMethodCardId(
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
) ? (
|
||||
modalUsesWizardFieldBlocksBody ? (
|
||||
<CustomMethodCardModalBody
|
||||
key={pendingCardId}
|
||||
cardId={pendingCardId}
|
||||
blocksById={state.customMethodCardFieldBlocksById}
|
||||
blocksOverride={
|
||||
modalEditUnlocked && draftFieldBlocks !== null
|
||||
? draftFieldBlocks
|
||||
: undefined
|
||||
}
|
||||
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
||||
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
|
||||
onFieldBlocksChange={
|
||||
customModalReadOnly ? undefined : onCustomFieldBlocksChange
|
||||
fieldsLocked
|
||||
? undefined
|
||||
: (next) => setDraftFieldBlocks(next)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ConflictManagementEditFields
|
||||
key={pendingCardId}
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
readOnly={fieldsLocked}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* DB-driven content.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
@@ -35,22 +35,52 @@ import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/custo
|
||||
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
||||
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
|
||||
import { membershipMethodFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId";
|
||||
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
|
||||
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
|
||||
import {
|
||||
cloneMethodCardBlocksForDuplicate,
|
||||
cloneMethodCardDetailsForDuplicate,
|
||||
duplicateMethodCardTitle,
|
||||
forkMethodCardFacetMapsForDuplicate,
|
||||
omitIdFromStringRecord,
|
||||
} from "../../../../../lib/create/duplicateMethodCardModalDraft";
|
||||
import type { MembershipMethodDetailEntry } from "../../types";
|
||||
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||
import { useCustomMethodCardFieldBlocksChange } from "../../hooks/useCustomMethodCardFieldBlocksChange";
|
||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
||||
|
||||
export function MembershipMethodsScreen() {
|
||||
const m = useMessages();
|
||||
const mem = m.create.customRule.membership;
|
||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||
useCreateFlow();
|
||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||
const customizeSnapshotRef = useRef<
|
||||
MethodCardCustomizeSnapshot<MembershipMethodDetailEntry> | null
|
||||
>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
|
||||
const [pendingDraft, setPendingDraft] =
|
||||
useState<MembershipMethodDetailEntry | null>(null);
|
||||
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
||||
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||
CustomMethodCardFieldBlock[] | null
|
||||
>(null);
|
||||
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
||||
useState<MethodCardHeaderDraft | null>(null);
|
||||
|
||||
const selectedIds = state.selectedMembershipMethodIds ?? [];
|
||||
|
||||
@@ -95,24 +125,6 @@ export function MembershipMethodsScreen() {
|
||||
</>
|
||||
);
|
||||
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
const alreadySelected = selectedIds.includes(pendingCardId);
|
||||
return {
|
||||
title: method?.label ?? mem.confirmModal.title,
|
||||
description: method?.supportText ?? mem.confirmModal.description,
|
||||
nextButtonText: alreadySelected
|
||||
? mem.removePlatform.nextButtonText
|
||||
: mem.addPlatform.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: mem.confirmModal.title,
|
||||
description: mem.confirmModal.description,
|
||||
nextButtonText: mem.confirmModal.nextButtonText,
|
||||
};
|
||||
|
||||
const seedDraft = useCallback(
|
||||
(id: string): MembershipMethodDetailEntry => {
|
||||
const saved = state.membershipMethodDetailsById?.[id];
|
||||
@@ -127,6 +139,10 @@ export function MembershipMethodsScreen() {
|
||||
const handleCardClick = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
setPendingCardId(id);
|
||||
setPendingDraft(seedDraft(id));
|
||||
setCreateModalOpen(true);
|
||||
@@ -142,17 +158,390 @@ export function MembershipMethodsScreen() {
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
|
||||
createModalOpen ? pendingCardId : null,
|
||||
);
|
||||
const customModalReadOnly =
|
||||
const isSelectedCardModal =
|
||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||
const fieldsLocked = !modalEditUnlocked;
|
||||
|
||||
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
|
||||
|
||||
const customFacetDetailsMatchPreset = useMemo(() => {
|
||||
if (!pendingCardId || !pendingDraft) return false;
|
||||
if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) {
|
||||
return false;
|
||||
}
|
||||
return membershipMethodFacetMatchesPreset(pendingDraft, pendingCardId);
|
||||
}, [
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const modalUsesWizardFieldBlocksBody = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
pendingCardId &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
}),
|
||||
),
|
||||
[
|
||||
customFacetDetailsMatchPreset,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
const ephemeralId = pendingEphemeralDuplicateIdRef.current;
|
||||
if (ephemeralId) {
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
replaceState((prev) => ({
|
||||
...prev,
|
||||
customMethodCardMetaById: omitIdFromStringRecord(
|
||||
prev.customMethodCardMetaById,
|
||||
ephemeralId,
|
||||
),
|
||||
membershipMethodDetailsById: omitIdFromStringRecord(
|
||||
prev.membershipMethodDetailsById,
|
||||
ephemeralId,
|
||||
),
|
||||
customMethodCardFieldBlocksById: omitIdFromStringRecord(
|
||||
prev.customMethodCardFieldBlocksById,
|
||||
ephemeralId,
|
||||
),
|
||||
}));
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
setPendingDraft(null);
|
||||
}, []);
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
replaceState,
|
||||
]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
if (!modalEditUnlocked) {
|
||||
return;
|
||||
}
|
||||
const snap = customizeSnapshotRef.current;
|
||||
if (!snap) {
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
snap,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPendingDraft(structuredClone(snap.pendingDraft));
|
||||
setDraftFieldBlocks(null);
|
||||
setModalEditUnlocked(false);
|
||||
customizeSnapshotRef.current = null;
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
]);
|
||||
|
||||
const handleRemoveSelectedFromModal = useCallback(() => {
|
||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
updateState(
|
||||
removeMethodCardFromFacetSelection(state, "membership", pendingCardId),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
pendingCardId,
|
||||
selectedIds,
|
||||
state,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleCustomize = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (!pendingDraft || !pendingCardId) {
|
||||
return;
|
||||
}
|
||||
const initialFieldBlocks =
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? structuredClone(
|
||||
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
|
||||
)
|
||||
: null;
|
||||
const method = methodById.get(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const headerDraft: MethodCardHeaderDraft = {
|
||||
title: meta?.label ?? method?.label ?? mem.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
mem.confirmModal.description,
|
||||
};
|
||||
setCustomizeHeaderDraft(headerDraft);
|
||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||
pendingDraft,
|
||||
initialFieldBlocks,
|
||||
headerDraft,
|
||||
);
|
||||
setDraftFieldBlocks(initialFieldBlocks);
|
||||
setModalEditUnlocked(true);
|
||||
}, [
|
||||
mem.confirmModal.description,
|
||||
mem.confirmModal.title,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const handleDuplicateCustomCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const meta = state.customMethodCardMetaById![pendingCardId]!;
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.membershipMethodDetailsById?.[pendingCardId],
|
||||
() => membershipPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.membershipMethodDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(meta.label, suffix),
|
||||
supportText: meta.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
membershipMethodDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
draftFieldBlocks,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
state.membershipMethodDetailsById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleDuplicatePrefabCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const method = methodById.get(pendingCardId);
|
||||
if (!method || !pendingDraft) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.membershipMethodDetailsById?.[pendingCardId],
|
||||
() => membershipPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.membershipMethodDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(method.label, suffix),
|
||||
supportText: method.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
membershipMethodDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
draftFieldBlocks,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
state.membershipMethodDetailsById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const kebabMenuItems = useMemo(
|
||||
() =>
|
||||
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||
showCustomize: !modalEditUnlocked,
|
||||
onCustomize: handleCustomize,
|
||||
onDuplicate:
|
||||
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
||||
? undefined
|
||||
: isCustomMethodCardId(
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
)
|
||||
? handleDuplicateCustomCard
|
||||
: handleDuplicatePrefabCard,
|
||||
showRemove: isSelectedCardModal,
|
||||
onRemove: handleRemoveSelectedFromModal,
|
||||
}),
|
||||
[
|
||||
handleCustomize,
|
||||
handleDuplicateCustomCard,
|
||||
handleDuplicatePrefabCard,
|
||||
handleRemoveSelectedFromModal,
|
||||
isSelectedCardModal,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu,
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
state.editingPublishedRuleId,
|
||||
],
|
||||
);
|
||||
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const saveLabel = modalKebabMenu.saveEdits;
|
||||
return {
|
||||
title: meta?.label ?? method?.label ?? mem.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
mem.confirmModal.description,
|
||||
nextButtonText: modalEditUnlocked
|
||||
? saveLabel
|
||||
: mem.addPlatform.nextButtonText,
|
||||
};
|
||||
})()
|
||||
: {
|
||||
title: mem.confirmModal.title,
|
||||
description: mem.confirmModal.description,
|
||||
nextButtonText: mem.confirmModal.nextButtonText,
|
||||
};
|
||||
|
||||
const handleCloseAddWizard = useCallback(() => {
|
||||
setAddCustomWizardOpen(false);
|
||||
@@ -205,13 +594,98 @@ export function MembershipMethodsScreen() {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
|
||||
if (selectedIds.includes(pendingCardId)) {
|
||||
updateState(
|
||||
removeMethodCardFromFacetSelection(state, "membership", pendingCardId),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
membershipMethodDetailsById: {
|
||||
...(state.membershipMethodDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
membershipMethodDetailsById: {
|
||||
...(state.membershipMethodDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingDraft) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
@@ -226,10 +700,14 @@ export function MembershipMethodsScreen() {
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
selectedIds,
|
||||
@@ -274,31 +752,62 @@ export function MembershipMethodsScreen() {
|
||||
<Create
|
||||
isOpen={createModalOpen}
|
||||
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}
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={false}
|
||||
showBackButton={modalEditUnlocked}
|
||||
onBack={handleCancelCustomize}
|
||||
backButtonText={modalKebabMenu.cancelCustomize}
|
||||
showNextButton={showMethodModalPrimary}
|
||||
backdropVariant="blurredYellow"
|
||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||
kebabMenuItems={kebabMenuItems}
|
||||
>
|
||||
{pendingCardId && pendingDraft ? (
|
||||
isCustomMethodCardId(
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
) ? (
|
||||
modalUsesWizardFieldBlocksBody ? (
|
||||
<CustomMethodCardModalBody
|
||||
key={pendingCardId}
|
||||
cardId={pendingCardId}
|
||||
blocksById={state.customMethodCardFieldBlocksById}
|
||||
blocksOverride={
|
||||
modalEditUnlocked && draftFieldBlocks !== null
|
||||
? draftFieldBlocks
|
||||
: undefined
|
||||
}
|
||||
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
||||
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
|
||||
onFieldBlocksChange={
|
||||
customModalReadOnly ? undefined : onCustomFieldBlocksChange
|
||||
fieldsLocked
|
||||
? undefined
|
||||
: (next) => setDraftFieldBlocks(next)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MembershipMethodEditFields
|
||||
key={pendingCardId}
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
readOnly={fieldsLocked}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function FinalReviewScreen({
|
||||
}: {
|
||||
variant?: "default" | "editPublished";
|
||||
} = {}) {
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const { state, updateState, replaceState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const { goToStep } = useCreateFlowNavigation();
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const t = useTranslation("create.reviewAndComplete.finalReview");
|
||||
@@ -96,11 +96,11 @@ export function FinalReviewScreen({
|
||||
/**
|
||||
* Two modals coexist on this screen:
|
||||
*
|
||||
* - {@link FinalReviewChipEditModal} — editable Save-button version used
|
||||
* whenever the chip resolves to a stable `overrideKey` (core-value
|
||||
* chip id, or a method preset id). Writes through to
|
||||
* `{group}DetailsById` state fields on Save; close-without-save is a
|
||||
* no-op so any typed edits are discarded.
|
||||
* - {@link FinalReviewChipEditModal} — core values + method chips: kebab
|
||||
* Customize / Remove; values also offer Duplicate under the five-chip cap.
|
||||
* Save respects the same unlock/dirty rules as the facet create modals;
|
||||
* writes `{group}DetailsById`, snapshot label (values), `customMethodCardMetaById`,
|
||||
* and field blocks on Save.
|
||||
* - {@link TemplateChipDetailModal} — read-only fallback for chips we
|
||||
* can't map to an override key (e.g. template body entries on the
|
||||
* "Use without changes" path where no preset matches the title).
|
||||
@@ -268,6 +268,9 @@ export function FinalReviewScreen({
|
||||
target={activeEditTarget}
|
||||
state={state}
|
||||
onSave={handleSave}
|
||||
replaceState={replaceState}
|
||||
onInteract={markCreateFlowInteraction}
|
||||
onEditTargetChange={setActiveEditTarget}
|
||||
/>
|
||||
<TemplateChipDetailModal
|
||||
isOpen={activeReadOnlyDetail !== null}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* replaced with DB-driven content.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import CardStack from "../../../../components/cards/CardStack";
|
||||
import HeaderLockup from "../../../../components/type/HeaderLockup";
|
||||
import Create from "../../../../components/modals/Create";
|
||||
@@ -36,16 +36,40 @@ import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/custo
|
||||
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
|
||||
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
|
||||
import { decisionApproachFacetMatchesPreset } from "../../../../../lib/create/methodCardFacetMatchesPresetForId";
|
||||
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
|
||||
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
|
||||
import {
|
||||
cloneMethodCardBlocksForDuplicate,
|
||||
cloneMethodCardDetailsForDuplicate,
|
||||
duplicateMethodCardTitle,
|
||||
forkMethodCardFacetMapsForDuplicate,
|
||||
omitIdFromStringRecord,
|
||||
} from "../../../../../lib/create/duplicateMethodCardModalDraft";
|
||||
import type { DecisionApproachDetailEntry } from "../../types";
|
||||
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
|
||||
import { useCustomMethodCardFieldBlocksChange } from "../../hooks/useCustomMethodCardFieldBlocksChange";
|
||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||
import { methodCardMetaWithCustomizeHeader } from "../../../../../lib/create/methodCardCustomizeMetaPatch";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
||||
|
||||
export function DecisionApproachesScreen() {
|
||||
const m = useMessages();
|
||||
const da = m.create.customRule.decisionApproaches;
|
||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const { state, updateState, replaceState, markCreateFlowInteraction } =
|
||||
useCreateFlow();
|
||||
const pendingEphemeralDuplicateIdRef = useRef<string | null>(null);
|
||||
const customizeSnapshotRef = useRef<
|
||||
MethodCardCustomizeSnapshot<DecisionApproachDetailEntry> | null
|
||||
>(null);
|
||||
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
@@ -55,6 +79,12 @@ export function DecisionApproachesScreen() {
|
||||
const [pendingDraft, setPendingDraft] =
|
||||
useState<DecisionApproachDetailEntry | null>(null);
|
||||
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
|
||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
||||
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
|
||||
CustomMethodCardFieldBlock[] | null
|
||||
>(null);
|
||||
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
||||
useState<MethodCardHeaderDraft | null>(null);
|
||||
|
||||
const selectedIds = state.selectedDecisionApproachIds ?? [];
|
||||
|
||||
@@ -126,6 +156,10 @@ export function DecisionApproachesScreen() {
|
||||
const handleCardSelect = useCallback(
|
||||
(id: string) => {
|
||||
markCreateFlowInteraction();
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
setPendingCardId(id);
|
||||
setPendingDraft(seedDraft(id));
|
||||
setCreateModalOpen(true);
|
||||
@@ -141,23 +175,378 @@ export function DecisionApproachesScreen() {
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
|
||||
createModalOpen ? pendingCardId : null,
|
||||
);
|
||||
const customModalReadOnly =
|
||||
const isSelectedCardModal =
|
||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||
const fieldsLocked = !modalEditUnlocked;
|
||||
|
||||
const showMethodModalPrimary = !isSelectedCardModal || modalEditUnlocked;
|
||||
|
||||
const customFacetDetailsMatchPreset = useMemo(() => {
|
||||
if (!pendingCardId || !pendingDraft) return false;
|
||||
if (!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)) {
|
||||
return false;
|
||||
}
|
||||
return decisionApproachFacetMatchesPreset(pendingDraft, pendingCardId);
|
||||
}, [
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const modalUsesWizardFieldBlocksBody = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
pendingCardId &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
}),
|
||||
),
|
||||
[
|
||||
customFacetDetailsMatchPreset,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
const ephemeralId = pendingEphemeralDuplicateIdRef.current;
|
||||
if (ephemeralId) {
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
replaceState((prev) => ({
|
||||
...prev,
|
||||
customMethodCardMetaById: omitIdFromStringRecord(
|
||||
prev.customMethodCardMetaById,
|
||||
ephemeralId,
|
||||
),
|
||||
decisionApproachDetailsById: omitIdFromStringRecord(
|
||||
prev.decisionApproachDetailsById,
|
||||
ephemeralId,
|
||||
),
|
||||
customMethodCardFieldBlocksById: omitIdFromStringRecord(
|
||||
prev.customMethodCardFieldBlocksById,
|
||||
ephemeralId,
|
||||
),
|
||||
}));
|
||||
}
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
setPendingDraft(null);
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
replaceState,
|
||||
]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
if (!modalEditUnlocked) {
|
||||
return;
|
||||
}
|
||||
const snap = customizeSnapshotRef.current;
|
||||
if (!snap) {
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
snap,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPendingDraft(structuredClone(snap.pendingDraft));
|
||||
setDraftFieldBlocks(null);
|
||||
setModalEditUnlocked(false);
|
||||
customizeSnapshotRef.current = null;
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
]);
|
||||
|
||||
const handleRemoveSelectedFromModal = useCallback(() => {
|
||||
if (!pendingCardId || !selectedIds.includes(pendingCardId)) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
customizeSnapshotRef.current,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
updateState(
|
||||
removeMethodCardFromFacetSelection(
|
||||
state,
|
||||
"decisionApproaches",
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
pendingDraft,
|
||||
pendingCardId,
|
||||
selectedIds,
|
||||
state,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleCustomize = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
if (!pendingDraft || !pendingCardId) {
|
||||
return;
|
||||
}
|
||||
const initialFieldBlocks =
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? structuredClone(
|
||||
state.customMethodCardFieldBlocksById?.[pendingCardId] ?? [],
|
||||
)
|
||||
: null;
|
||||
const method = methodById.get(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const headerDraft: MethodCardHeaderDraft = {
|
||||
title: meta?.label ?? method?.label ?? da.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
da.confirmModal.description,
|
||||
};
|
||||
setCustomizeHeaderDraft(headerDraft);
|
||||
customizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||
pendingDraft,
|
||||
initialFieldBlocks,
|
||||
headerDraft,
|
||||
);
|
||||
setDraftFieldBlocks(initialFieldBlocks);
|
||||
setModalEditUnlocked(true);
|
||||
}, [
|
||||
da.confirmModal.description,
|
||||
da.confirmModal.title,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
]);
|
||||
|
||||
const handleDuplicateCustomCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
!isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const meta = state.customMethodCardMetaById![pendingCardId]!;
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.decisionApproachDetailsById?.[pendingCardId],
|
||||
() => decisionApproachPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.decisionApproachDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(meta.label, suffix),
|
||||
supportText: meta.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
decisionApproachDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
draftFieldBlocks,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
state.decisionApproachDetailsById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const handleDuplicatePrefabCard = useCallback(() => {
|
||||
if (
|
||||
!pendingCardId ||
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const method = methodById.get(pendingCardId);
|
||||
if (!method || !pendingDraft) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const newId = crypto.randomUUID();
|
||||
const detailsClone = cloneMethodCardDetailsForDuplicate(
|
||||
pendingDraft,
|
||||
state.decisionApproachDetailsById?.[pendingCardId],
|
||||
() => decisionApproachPresetFor(newId),
|
||||
);
|
||||
const blocksClone = structuredClone(
|
||||
modalEditUnlocked &&
|
||||
draftFieldBlocks !== null &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById)
|
||||
? draftFieldBlocks
|
||||
: cloneMethodCardBlocksForDuplicate(
|
||||
state.customMethodCardFieldBlocksById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
const suffix = modalKebabMenu.duplicateTitleSuffix;
|
||||
const priorEphemeral = pendingEphemeralDuplicateIdRef.current;
|
||||
const maps = forkMethodCardFacetMapsForDuplicate({
|
||||
customMethodCardMetaById: state.customMethodCardMetaById,
|
||||
facetDetailsById: state.decisionApproachDetailsById,
|
||||
customMethodCardFieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
omitId: priorEphemeral,
|
||||
});
|
||||
maps.customMethodCardMetaById[newId] = {
|
||||
label: duplicateMethodCardTitle(method.label, suffix),
|
||||
supportText: method.supportText,
|
||||
};
|
||||
maps.facetDetailsById[newId] = detailsClone;
|
||||
maps.customMethodCardFieldBlocksById[newId] = blocksClone;
|
||||
updateState({
|
||||
customMethodCardMetaById: maps.customMethodCardMetaById,
|
||||
decisionApproachDetailsById: maps.facetDetailsById,
|
||||
customMethodCardFieldBlocksById: maps.customMethodCardFieldBlocksById,
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = newId;
|
||||
customizeSnapshotRef.current = null;
|
||||
setPendingCardId(newId);
|
||||
setPendingDraft(structuredClone(detailsClone));
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, [
|
||||
draftFieldBlocks,
|
||||
markCreateFlowInteraction,
|
||||
methodById,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
state.customMethodCardMetaById,
|
||||
state.decisionApproachDetailsById,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const kebabMenuItems = useMemo(
|
||||
() =>
|
||||
buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||
showCustomize: !modalEditUnlocked,
|
||||
onCustomize: handleCustomize,
|
||||
onDuplicate:
|
||||
(state.editingPublishedRuleId?.trim() ?? "") !== "" || !pendingCardId
|
||||
? undefined
|
||||
: isCustomMethodCardId(
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
)
|
||||
? handleDuplicateCustomCard
|
||||
: handleDuplicatePrefabCard,
|
||||
showRemove: isSelectedCardModal,
|
||||
onRemove: handleRemoveSelectedFromModal,
|
||||
}),
|
||||
[
|
||||
handleCustomize,
|
||||
handleDuplicateCustomCard,
|
||||
handleDuplicatePrefabCard,
|
||||
handleRemoveSelectedFromModal,
|
||||
isSelectedCardModal,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu,
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
state.editingPublishedRuleId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
setExpanded((prev) => !prev);
|
||||
}, [markCreateFlowInteraction]);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
setPendingCardId(null);
|
||||
setPendingDraft(null);
|
||||
}, []);
|
||||
|
||||
const handleCloseAddWizard = useCallback(() => {
|
||||
setAddCustomWizardOpen(false);
|
||||
}, []);
|
||||
@@ -209,17 +598,98 @@ export function DecisionApproachesScreen() {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
|
||||
if (selectedIds.includes(pendingCardId)) {
|
||||
updateState(
|
||||
removeMethodCardFromFacetSelection(
|
||||
state,
|
||||
"decisionApproaches",
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
),
|
||||
);
|
||||
handleCreateModalClose();
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
decisionApproachDetailsById: {
|
||||
...(state.decisionApproachDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalEditUnlocked) {
|
||||
if (!customizeHeaderDraft) {
|
||||
return;
|
||||
}
|
||||
const nextMeta = methodCardMetaWithCustomizeHeader(
|
||||
state.customMethodCardMetaById,
|
||||
pendingCardId,
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
if (
|
||||
pendingCardId &&
|
||||
isCustomMethodCardId(pendingCardId, state.customMethodCardMetaById) &&
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: pendingCardId,
|
||||
meta: state.customMethodCardMetaById,
|
||||
fieldBlocksById: state.customMethodCardFieldBlocksById,
|
||||
modalEditUnlocked,
|
||||
draftFieldBlocks,
|
||||
customFacetDetailsMatchPreset,
|
||||
})
|
||||
) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[pendingCardId]: structuredClone(draftFieldBlocks ?? []),
|
||||
},
|
||||
});
|
||||
} else if (pendingDraft) {
|
||||
updateState({
|
||||
customMethodCardMetaById: nextMeta,
|
||||
decisionApproachDetailsById: {
|
||||
...(state.decisionApproachDetailsById ?? {}),
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
}
|
||||
customizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setDraftFieldBlocks(null);
|
||||
setCustomizeHeaderDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingDraft) {
|
||||
handleCreateModalClose();
|
||||
return;
|
||||
@@ -234,10 +704,14 @@ export function DecisionApproachesScreen() {
|
||||
[pendingCardId]: pendingDraft,
|
||||
},
|
||||
});
|
||||
pendingEphemeralDuplicateIdRef.current = null;
|
||||
handleCreateModalClose();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draftFieldBlocks,
|
||||
handleCreateModalClose,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
pendingCardId,
|
||||
pendingDraft,
|
||||
selectedIds,
|
||||
@@ -246,14 +720,18 @@ export function DecisionApproachesScreen() {
|
||||
]);
|
||||
|
||||
const modalConfig = pendingCardId
|
||||
? (() => {
|
||||
? (() => {
|
||||
const method = methodById.get(pendingCardId);
|
||||
const alreadySelected = selectedIds.includes(pendingCardId);
|
||||
const meta = state.customMethodCardMetaById?.[pendingCardId];
|
||||
const saveLabel = modalKebabMenu.saveEdits;
|
||||
return {
|
||||
title: method?.label ?? da.confirmModal.title,
|
||||
description: method?.supportText ?? da.confirmModal.description,
|
||||
nextButtonText: alreadySelected
|
||||
? da.removeApproach.nextButtonText
|
||||
title: meta?.label ?? method?.label ?? da.confirmModal.title,
|
||||
description:
|
||||
meta?.supportText ??
|
||||
method?.supportText ??
|
||||
da.confirmModal.description,
|
||||
nextButtonText: modalEditUnlocked
|
||||
? saveLabel
|
||||
: da.addApproach.nextButtonText,
|
||||
};
|
||||
})()
|
||||
@@ -320,31 +798,62 @@ export function DecisionApproachesScreen() {
|
||||
<Create
|
||||
isOpen={createModalOpen}
|
||||
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}
|
||||
title={modalConfig.title}
|
||||
description={modalConfig.description}
|
||||
nextButtonText={modalConfig.nextButtonText}
|
||||
showBackButton={false}
|
||||
showBackButton={modalEditUnlocked}
|
||||
onBack={handleCancelCustomize}
|
||||
backButtonText={modalKebabMenu.cancelCustomize}
|
||||
showNextButton={showMethodModalPrimary}
|
||||
backdropVariant="blurredYellow"
|
||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||
kebabMenuItems={kebabMenuItems}
|
||||
>
|
||||
{pendingCardId && pendingDraft ? (
|
||||
isCustomMethodCardId(
|
||||
pendingCardId,
|
||||
state.customMethodCardMetaById,
|
||||
) ? (
|
||||
modalUsesWizardFieldBlocksBody ? (
|
||||
<CustomMethodCardModalBody
|
||||
key={pendingCardId}
|
||||
cardId={pendingCardId}
|
||||
blocksById={state.customMethodCardFieldBlocksById}
|
||||
blocksOverride={
|
||||
modalEditUnlocked && draftFieldBlocks !== null
|
||||
? draftFieldBlocks
|
||||
: undefined
|
||||
}
|
||||
policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
|
||||
showPolicyContentLockupWhenNoBlocks={!modalEditUnlocked}
|
||||
onFieldBlocksChange={
|
||||
customModalReadOnly ? undefined : onCustomFieldBlocksChange
|
||||
fieldsLocked
|
||||
? undefined
|
||||
: (next) => setDraftFieldBlocks(next)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DecisionApproachEditFields
|
||||
key={pendingCardId}
|
||||
value={pendingDraft}
|
||||
onChange={handleDraftChange}
|
||||
readOnly={fieldsLocked}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||
import Create from "../../../../components/modals/Create";
|
||||
@@ -15,8 +15,23 @@ import type {
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
import { CoreValueEditFields } from "../../components/methodEditFields";
|
||||
import MethodCardCustomizeModalHeader from "../../components/MethodCardCustomizeModalHeader";
|
||||
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
type MethodCardCustomizeSnapshot,
|
||||
type MethodCardHeaderDraft,
|
||||
} from "../../../../../lib/create/methodCardCustomizeSession";
|
||||
import {
|
||||
duplicateCoreValueChipInDraft,
|
||||
MAX_SELECTED_CORE_VALUES,
|
||||
removeCoreValueChipFromDraft,
|
||||
} from "../../../../../lib/create/coreValueChipFacet";
|
||||
import { omitIdFromStringRecord } from "../../../../../lib/create/duplicateMethodCardModalDraft";
|
||||
|
||||
const MAX_CORE_VALUES = 5;
|
||||
const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
|
||||
|
||||
/**
|
||||
* Why three sessions, not two:
|
||||
@@ -80,12 +95,18 @@ const EMPTY_DETAIL: CoreValueDetailEntry = { meaning: "", signals: "" };
|
||||
export function CoreValuesSelectScreen() {
|
||||
const m = useMessages();
|
||||
const cv = m.create.customRule.coreValues;
|
||||
const modalKebabMenu = m.create.customRule.modalKebabMenu;
|
||||
const presets = useMemo(
|
||||
() => normalizeCoreValuePresets(cv.values as CoreValuePresetJson[]),
|
||||
[cv.values],
|
||||
);
|
||||
|
||||
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
|
||||
const { markCreateFlowInteraction, updateState, replaceState, state } =
|
||||
useCreateFlow();
|
||||
|
||||
const coreCustomizeSnapshotRef =
|
||||
useRef<MethodCardCustomizeSnapshot<CoreValueDetailEntry> | null>(null);
|
||||
const pendingEphemeralCoreDuplicateRef = useRef<string | null>(null);
|
||||
|
||||
const [coreValueOptions, setCoreValueOptions] = useState<ChipOption[]>(() =>
|
||||
buildCoreValueChipOptionsFromDraft(
|
||||
@@ -100,6 +121,9 @@ export function CoreValuesSelectScreen() {
|
||||
);
|
||||
const [modalSession, setModalSession] = useState<ModalSession | null>(null);
|
||||
const [draft, setDraft] = useState<CoreValueDetailEntry>(EMPTY_DETAIL);
|
||||
const [modalEditUnlocked, setModalEditUnlocked] = useState(false);
|
||||
const [customizeHeaderDraft, setCustomizeHeaderDraft] =
|
||||
useState<MethodCardHeaderDraft | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCoreValueOptions(
|
||||
@@ -158,10 +182,18 @@ export function CoreValuesSelectScreen() {
|
||||
);
|
||||
|
||||
const openModal = useCallback(
|
||||
(chipId: string, session: ModalSession, valueLabel: string) => {
|
||||
setDraft(getInitialTexts(chipId, valueLabel));
|
||||
(
|
||||
chipId: string,
|
||||
session: ModalSession,
|
||||
valueLabel: string,
|
||||
seedDetail?: CoreValueDetailEntry,
|
||||
) => {
|
||||
setDraft(seedDetail ?? getInitialTexts(chipId, valueLabel));
|
||||
setActiveModalChipId(chipId);
|
||||
setModalSession(session);
|
||||
setModalEditUnlocked(false);
|
||||
setCustomizeHeaderDraft(null);
|
||||
coreCustomizeSnapshotRef.current = null;
|
||||
markCreateFlowInteraction();
|
||||
},
|
||||
[getInitialTexts, markCreateFlowInteraction],
|
||||
@@ -175,46 +207,347 @@ export function CoreValuesSelectScreen() {
|
||||
[markCreateFlowInteraction],
|
||||
);
|
||||
|
||||
const handleModalDismiss = useCallback(() => {
|
||||
if (activeModalChipId && modalSession === "pending") {
|
||||
const resetCustomizeSession = useCallback(() => {
|
||||
coreCustomizeSnapshotRef.current = null;
|
||||
setModalEditUnlocked(false);
|
||||
setCustomizeHeaderDraft(null);
|
||||
}, []);
|
||||
|
||||
const finalizeModalDismiss = useCallback(() => {
|
||||
pendingEphemeralCoreDuplicateRef.current = null;
|
||||
resetCustomizeSession();
|
||||
setActiveModalChipId(null);
|
||||
setModalSession(null);
|
||||
}, [resetCustomizeSession]);
|
||||
|
||||
const handleCustomize = useCallback(() => {
|
||||
if (!activeModalChipId) return;
|
||||
const chipLabelNow =
|
||||
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
|
||||
if (!chipLabelNow) return;
|
||||
markCreateFlowInteraction();
|
||||
const headerDraft: MethodCardHeaderDraft = {
|
||||
title: chipLabelNow,
|
||||
description: "",
|
||||
};
|
||||
coreCustomizeSnapshotRef.current = captureMethodCardCustomizeSnapshot(
|
||||
draft,
|
||||
null,
|
||||
headerDraft,
|
||||
);
|
||||
setCustomizeHeaderDraft(headerDraft);
|
||||
setModalEditUnlocked(true);
|
||||
}, [activeModalChipId, coreValueOptions, draft, markCreateFlowInteraction]);
|
||||
|
||||
const handleCancelCustomize = useCallback(() => {
|
||||
if (!modalEditUnlocked) return;
|
||||
const snap = coreCustomizeSnapshotRef.current;
|
||||
if (!snap) {
|
||||
resetCustomizeSession();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isMethodCardCustomizeSessionDirty(snap, draft, null, customizeHeaderDraft) &&
|
||||
!window.confirm(modalKebabMenu.discardUnsavedCustomizeChanges)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setDraft(structuredClone(snap.pendingDraft));
|
||||
resetCustomizeSession();
|
||||
}, [
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
resetCustomizeSession,
|
||||
]);
|
||||
|
||||
const syncLabelFromCustomizeHeaderToOptions = useCallback(() => {
|
||||
if (!activeModalChipId || !customizeHeaderDraft) return coreValueOptions;
|
||||
const trimmed = customizeHeaderDraft.title.trim();
|
||||
if (!trimmed) return coreValueOptions;
|
||||
return coreValueOptions.map((opt) =>
|
||||
opt.id === activeModalChipId ? { ...opt, label: trimmed } : opt,
|
||||
);
|
||||
}, [activeModalChipId, customizeHeaderDraft, coreValueOptions]);
|
||||
|
||||
const handleDuplicateCoreChip = useCallback(() => {
|
||||
if (!activeModalChipId || !modalSession) return;
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
coreCustomizeSnapshotRef.current,
|
||||
draft,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
const priorEphemeral = pendingEphemeralCoreDuplicateRef.current;
|
||||
let outcome: ReturnType<typeof duplicateCoreValueChipInDraft> | null = null;
|
||||
replaceState((prev) => {
|
||||
const base =
|
||||
priorEphemeral != null
|
||||
? { ...prev, ...removeCoreValueChipFromDraft(prev, priorEphemeral) }
|
||||
: prev;
|
||||
const res = duplicateCoreValueChipInDraft(
|
||||
base,
|
||||
activeModalChipId,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
);
|
||||
if (!res) {
|
||||
return base;
|
||||
}
|
||||
outcome = res;
|
||||
return { ...base, ...res.patch };
|
||||
});
|
||||
if (!outcome) {
|
||||
return;
|
||||
}
|
||||
resetCustomizeSession();
|
||||
pendingEphemeralCoreDuplicateRef.current = outcome.newId;
|
||||
openModal(
|
||||
outcome.newId,
|
||||
"editing",
|
||||
outcome.newLabel,
|
||||
structuredClone(draft),
|
||||
);
|
||||
}, [
|
||||
activeModalChipId,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
modalKebabMenu.duplicateTitleSuffix,
|
||||
modalSession,
|
||||
openModal,
|
||||
replaceState,
|
||||
resetCustomizeSession,
|
||||
]);
|
||||
|
||||
const handleRemoveFromKebab = useCallback(() => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
coreCustomizeSnapshotRef.current,
|
||||
draft,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
|
||||
const ep = pendingEphemeralCoreDuplicateRef.current;
|
||||
if (ep && activeModalChipId === ep) {
|
||||
replaceState((prev) => ({
|
||||
...prev,
|
||||
...removeCoreValueChipFromDraft(prev, ep),
|
||||
}));
|
||||
finalizeModalDismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalSession === "pending") {
|
||||
const next = coreValueOptions.map((opt) =>
|
||||
opt.id === activeModalChipId
|
||||
? { ...opt, state: "unselected" as const }
|
||||
: opt,
|
||||
);
|
||||
persistCoreValues(next);
|
||||
} else if (activeModalChipId && modalSession === "customPending") {
|
||||
// Custom chip never confirmed via Add Value — drop it from both
|
||||
// the local options and the create-flow draft so refresh / back
|
||||
// navigation doesn't resurrect a phantom chip.
|
||||
} else if (modalSession === "customPending") {
|
||||
const next = coreValueOptions.filter((opt) => opt.id !== activeModalChipId);
|
||||
persistCoreValues(next);
|
||||
} else if (modalSession === "editing" && activeModalChipId) {
|
||||
const nextFiltered = coreValueOptions.filter(
|
||||
(opt) => opt.id !== activeModalChipId,
|
||||
);
|
||||
markCreateFlowInteraction();
|
||||
replaceState((prev) => ({
|
||||
...prev,
|
||||
selectedCoreValueIds: selectedIdsFromOptions(nextFiltered),
|
||||
coreValuesChipsSnapshot:
|
||||
chipOptionsToSnapshotRows(nextFiltered),
|
||||
coreValueDetailsByChipId:
|
||||
omitIdFromStringRecord(prev.coreValueDetailsByChipId, activeModalChipId),
|
||||
}));
|
||||
setCoreValueOptions(nextFiltered);
|
||||
}
|
||||
finalizeModalDismiss();
|
||||
}, [
|
||||
activeModalChipId,
|
||||
coreValueOptions,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
finalizeModalDismiss,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
modalSession,
|
||||
persistCoreValues,
|
||||
replaceState,
|
||||
modalSession,
|
||||
persistCoreValues,
|
||||
]);
|
||||
|
||||
const handleModalDismiss = useCallback(() => {
|
||||
if (
|
||||
!confirmDiscardMethodCardCustomizeSession(
|
||||
modalEditUnlocked,
|
||||
coreCustomizeSnapshotRef.current,
|
||||
draft,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ep = pendingEphemeralCoreDuplicateRef.current;
|
||||
if (ep) {
|
||||
replaceState((prev) => ({
|
||||
...prev,
|
||||
...removeCoreValueChipFromDraft(prev, ep),
|
||||
}));
|
||||
}
|
||||
|
||||
if (modalSession === "pending" && activeModalChipId) {
|
||||
const next = coreValueOptions.map((opt) =>
|
||||
opt.id === activeModalChipId
|
||||
? { ...opt, state: "unselected" as const }
|
||||
: opt,
|
||||
);
|
||||
persistCoreValues(next);
|
||||
} else if (modalSession === "customPending" && activeModalChipId) {
|
||||
const next = coreValueOptions.filter(
|
||||
(opt) => opt.id !== activeModalChipId,
|
||||
);
|
||||
persistCoreValues(next);
|
||||
}
|
||||
setActiveModalChipId(null);
|
||||
setModalSession(null);
|
||||
}, [activeModalChipId, modalSession, coreValueOptions, persistCoreValues]);
|
||||
|
||||
const handleModalConfirm = useCallback(() => {
|
||||
if (!activeModalChipId) return;
|
||||
markCreateFlowInteraction();
|
||||
updateState({
|
||||
coreValueDetailsByChipId: {
|
||||
...(state.coreValueDetailsByChipId ?? {}),
|
||||
[activeModalChipId]: draft,
|
||||
},
|
||||
});
|
||||
setActiveModalChipId(null);
|
||||
setModalSession(null);
|
||||
finalizeModalDismiss();
|
||||
}, [
|
||||
activeModalChipId,
|
||||
coreValueOptions,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
finalizeModalDismiss,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu.discardUnsavedCustomizeChanges,
|
||||
modalSession,
|
||||
persistCoreValues,
|
||||
replaceState,
|
||||
]);
|
||||
|
||||
const coreCustomizeSaveDisabled = useMemo(() => {
|
||||
if (!modalEditUnlocked) return false;
|
||||
const snap = coreCustomizeSnapshotRef.current;
|
||||
if (!snap) return true;
|
||||
return !isMethodCardCustomizeSessionDirty(
|
||||
snap,
|
||||
draft,
|
||||
null,
|
||||
customizeHeaderDraft,
|
||||
);
|
||||
}, [customizeHeaderDraft, draft, modalEditUnlocked]);
|
||||
|
||||
const handleModalConfirm = useCallback(() => {
|
||||
if (!activeModalChipId || !modalSession) return;
|
||||
|
||||
if (modalEditUnlocked && customizeHeaderDraft) {
|
||||
if (coreCustomizeSaveDisabled) {
|
||||
return;
|
||||
}
|
||||
markCreateFlowInteraction();
|
||||
pendingEphemeralCoreDuplicateRef.current = null;
|
||||
const nextOpts = syncLabelFromCustomizeHeaderToOptions();
|
||||
persistCoreValues(nextOpts);
|
||||
updateState({
|
||||
coreValueDetailsByChipId: {
|
||||
...(state.coreValueDetailsByChipId ?? {}),
|
||||
[activeModalChipId]: draft,
|
||||
},
|
||||
});
|
||||
resetCustomizeSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalSession === "pending" || modalSession === "customPending") {
|
||||
markCreateFlowInteraction();
|
||||
pendingEphemeralCoreDuplicateRef.current = null;
|
||||
updateState({
|
||||
coreValueDetailsByChipId: {
|
||||
...(state.coreValueDetailsByChipId ?? {}),
|
||||
[activeModalChipId]: draft,
|
||||
},
|
||||
});
|
||||
resetCustomizeSession();
|
||||
setActiveModalChipId(null);
|
||||
setModalSession(null);
|
||||
}
|
||||
}, [
|
||||
activeModalChipId,
|
||||
coreCustomizeSaveDisabled,
|
||||
customizeHeaderDraft,
|
||||
draft,
|
||||
markCreateFlowInteraction,
|
||||
modalEditUnlocked,
|
||||
modalSession,
|
||||
persistCoreValues,
|
||||
resetCustomizeSession,
|
||||
state.coreValueDetailsByChipId,
|
||||
syncLabelFromCustomizeHeaderToOptions,
|
||||
updateState,
|
||||
]);
|
||||
|
||||
const modalChipLabel =
|
||||
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
|
||||
|
||||
const modalFieldsLocked =
|
||||
!modalEditUnlocked &&
|
||||
Boolean(
|
||||
modalSession === "pending" ||
|
||||
modalSession === "customPending" ||
|
||||
modalSession === "editing",
|
||||
);
|
||||
|
||||
const showFooterPrimary =
|
||||
modalEditUnlocked ||
|
||||
modalSession === "pending" ||
|
||||
modalSession === "customPending";
|
||||
|
||||
const kebabMenuItems = useMemo(() => {
|
||||
if (!modalSession || !activeModalChipId) return [];
|
||||
const selectedCount = coreValueOptions.filter(
|
||||
(o) => o.state === "selected",
|
||||
).length;
|
||||
return buildCustomRuleModalKebabMenu(modalKebabMenu, {
|
||||
showCustomize: !modalEditUnlocked,
|
||||
onCustomize: handleCustomize,
|
||||
onDuplicate:
|
||||
modalSession !== "editing" || selectedCount >= MAX_CORE_VALUES
|
||||
? undefined
|
||||
: handleDuplicateCoreChip,
|
||||
showRemove: true,
|
||||
onRemove: handleRemoveFromKebab,
|
||||
});
|
||||
}, [
|
||||
activeModalChipId,
|
||||
coreValueOptions,
|
||||
handleCustomize,
|
||||
handleDuplicateCoreChip,
|
||||
handleRemoveFromKebab,
|
||||
modalEditUnlocked,
|
||||
modalKebabMenu,
|
||||
modalSession,
|
||||
]);
|
||||
const handleChipClick = (chipId: string) => {
|
||||
const target = coreValueOptions.find((o) => o.id === chipId);
|
||||
if (!target || target.state === "custom") return;
|
||||
@@ -224,12 +557,7 @@ export function CoreValuesSelectScreen() {
|
||||
).length;
|
||||
|
||||
if (target.state === "selected") {
|
||||
const next: ChipOption[] = coreValueOptions.map((opt) =>
|
||||
opt.id === chipId
|
||||
? { ...opt, state: "unselected" as const }
|
||||
: opt,
|
||||
);
|
||||
persistCoreValues(next);
|
||||
openModal(chipId, "editing", target.label);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -295,9 +623,6 @@ export function CoreValuesSelectScreen() {
|
||||
},
|
||||
};
|
||||
|
||||
const modalChipLabel =
|
||||
coreValueOptions.find((o) => o.id === activeModalChipId)?.label ?? "";
|
||||
|
||||
const description = (
|
||||
<>
|
||||
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
|
||||
@@ -348,22 +673,54 @@ export function CoreValuesSelectScreen() {
|
||||
onClose={handleModalDismiss}
|
||||
backdropVariant="blurredYellow"
|
||||
headerContent={
|
||||
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
||||
<ContentLockup
|
||||
title={modalChipLabel}
|
||||
description={detailModal.subtitle}
|
||||
variant="modal"
|
||||
alignment="left"
|
||||
modalEditUnlocked && customizeHeaderDraft ? (
|
||||
<MethodCardCustomizeModalHeader
|
||||
titleLabel={detailModal.customizeValueNameLabel}
|
||||
descriptionLabel=""
|
||||
titleValue={customizeHeaderDraft.title}
|
||||
descriptionValue=""
|
||||
onTitleChange={(title) =>
|
||||
setCustomizeHeaderDraft((prev) =>
|
||||
prev ? { ...prev, title } : null,
|
||||
)
|
||||
}
|
||||
onDescriptionChange={() => {}}
|
||||
showDescription={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
|
||||
<ContentLockup
|
||||
title={modalChipLabel}
|
||||
description={detailModal.subtitle}
|
||||
variant="modal"
|
||||
alignment="left"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
showBackButton={modalEditUnlocked}
|
||||
onBack={handleCancelCustomize}
|
||||
backButtonText={modalKebabMenu.cancelCustomize}
|
||||
showNextButton={showFooterPrimary}
|
||||
nextButtonDisabled={
|
||||
modalEditUnlocked && coreCustomizeSaveDisabled
|
||||
}
|
||||
showBackButton={false}
|
||||
showNextButton
|
||||
onNext={handleModalConfirm}
|
||||
nextButtonText={detailModal.addValueButton}
|
||||
nextButtonText={
|
||||
modalEditUnlocked ? modalKebabMenu.saveEdits : detailModal.addValueButton
|
||||
}
|
||||
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
|
||||
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
|
||||
kebabMenuItems={
|
||||
kebabMenuItems.length > 0 ? kebabMenuItems : undefined
|
||||
}
|
||||
ariaLabel={modalChipLabel || "Core value details"}
|
||||
>
|
||||
<CoreValueEditFields value={draft} onChange={handleDraftChange} />
|
||||
<CoreValueEditFields
|
||||
readOnly={modalFieldsLocked}
|
||||
value={draft}
|
||||
onChange={handleDraftChange}
|
||||
/>
|
||||
</Create>
|
||||
)}
|
||||
</CreateFlowTwoColumnSelectShell>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CREATE_ROUTES } from "./createFlowPaths";
|
||||
|
||||
export type CompletedStepExitRouter = { push: (_href: string) => void };
|
||||
|
||||
/**
|
||||
* Leaving `/create/completed` (post-publish shell or managing a rule from profile).
|
||||
*
|
||||
* Clears wizard client state only. Does **not** `DELETE /api/drafts/me` — the stored
|
||||
* draft may be unrelated in-progress work for another rule (one `RuleDraft`
|
||||
* row per authenticated user).
|
||||
*/
|
||||
export function runCompletedStepExit(opts: {
|
||||
clearState: () => void;
|
||||
clearAnonymousCreateFlowStorage: () => void;
|
||||
router: CompletedStepExitRouter;
|
||||
}): void {
|
||||
opts.clearState();
|
||||
opts.clearAnonymousCreateFlowStorage();
|
||||
opts.router.push(CREATE_ROUTES.root);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import ArrowBackIcon from "./arrow_back.svg";
|
||||
import ChevronRightIcon from "./chevron_right.svg";
|
||||
import ContentCopyIcon from "./content_copy.svg";
|
||||
import CsvIcon from "./csv.svg";
|
||||
import CustomIcon from "./custom.svg";
|
||||
import EditIcon from "./edit.svg";
|
||||
import ExclamationIcon from "./exclamation.svg";
|
||||
import ImageGlyphIcon from "./image.svg";
|
||||
@@ -23,6 +24,7 @@ export const ICON_NAME_OPTIONS = [
|
||||
"chevron_right",
|
||||
"content_copy",
|
||||
"csv",
|
||||
"custom",
|
||||
"edit",
|
||||
"exclamation",
|
||||
"image",
|
||||
@@ -48,6 +50,7 @@ const iconMap: Record<IconName, SvgComponent> = {
|
||||
chevron_right: ChevronRightIcon,
|
||||
content_copy: ContentCopyIcon,
|
||||
csv: CsvIcon,
|
||||
custom: CustomIcon,
|
||||
edit: EditIcon,
|
||||
exclamation: ExclamationIcon,
|
||||
image: ImageGlyphIcon,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.3636 8.27273L16.2273 5.77273L13.7273 4.63636L16.2273 3.5L17.3636 1L18.5 3.5L21 4.63636L18.5 5.77273L17.3636 8.27273ZM17.3636 21L16.2273 18.5L13.7273 17.3636L16.2273 16.2273L17.3636 13.7273L18.5 16.2273L21 17.3636L18.5 18.5L17.3636 21ZM8.27273 18.2727L6 13.2727L1 11L6 8.72727L8.27273 3.72727L10.5455 8.72727L15.5455 11L10.5455 13.2727L8.27273 18.2727Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 484 B |
@@ -7,4 +7,5 @@ export type ListItemProps = {
|
||||
/** Bottom divider between rows — false on the final row per Figma. */
|
||||
showDivider: boolean;
|
||||
className?: string;
|
||||
variant?: "default" | "destructive";
|
||||
};
|
||||
|
||||
@@ -10,10 +10,15 @@ export const ListItemView = memo(function ListItemView({
|
||||
onClick,
|
||||
showDivider,
|
||||
className = "",
|
||||
variant = "default",
|
||||
}: ListItemProps) {
|
||||
const dividerClass = showDivider
|
||||
? "border-b border-solid border-[var(--color-border-default-tertiary)]"
|
||||
: "";
|
||||
const contentTone =
|
||||
variant === "destructive"
|
||||
? "text-[var(--color-content-default-negative-primary)]"
|
||||
: "text-[var(--color-content-default-primary)]";
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -22,10 +27,14 @@ export const ListItemView = memo(function ListItemView({
|
||||
onClick={onClick}
|
||||
className={`relative flex w-full shrink-0 cursor-pointer items-center gap-[6px] px-[4px] py-[16px] text-left hover:bg-[var(--color-surface-default-tertiary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)] ${dividerClass} ${className}`}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center overflow-visible text-[var(--color-content-default-primary)]">
|
||||
<span
|
||||
className={`flex size-6 shrink-0 items-center justify-center overflow-visible ${contentTone}`}
|
||||
>
|
||||
<Icon name={leadingIcon} size={24} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-left font-inter text-[12px] font-normal leading-4 whitespace-normal text-[var(--color-content-default-primary)]">
|
||||
<span
|
||||
className={`min-w-0 flex-1 text-left font-inter text-[12px] font-normal leading-4 whitespace-normal ${contentTone}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -28,6 +28,9 @@ const CreateContainer = memo<CreateProps>(
|
||||
ariaLabelledBy,
|
||||
backdropVariant = "default",
|
||||
stepper,
|
||||
kebabTriggerAriaLabel,
|
||||
kebabMenuAriaLabel,
|
||||
kebabMenuItems,
|
||||
}) => {
|
||||
const createRef = useRef<HTMLDivElement>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
@@ -60,6 +63,9 @@ const CreateContainer = memo<CreateProps>(
|
||||
overlayRef={overlayRef}
|
||||
backdropVariant={backdropVariant}
|
||||
stepper={stepper}
|
||||
kebabTriggerAriaLabel={kebabTriggerAriaLabel}
|
||||
kebabMenuAriaLabel={kebabMenuAriaLabel}
|
||||
kebabMenuItems={kebabMenuItems}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RefObject } from "react";
|
||||
import type { CreateModalBackdropVariant } from "./CreateModalFrame.view";
|
||||
import type { ModalHeaderMenuItem } from "../ModalHeader/ModalHeader.types";
|
||||
|
||||
export interface CreateProps {
|
||||
isOpen: boolean;
|
||||
@@ -37,6 +38,9 @@ export interface CreateProps {
|
||||
backdropVariant?: CreateModalBackdropVariant;
|
||||
/** Passed through to ModalFooter; set explicitly when step visibility must not infer from steps alone. */
|
||||
stepper?: boolean;
|
||||
kebabTriggerAriaLabel?: string;
|
||||
kebabMenuAriaLabel?: string;
|
||||
kebabMenuItems?: ModalHeaderMenuItem[];
|
||||
}
|
||||
|
||||
export interface CreateViewProps {
|
||||
@@ -63,4 +67,7 @@ export interface CreateViewProps {
|
||||
overlayRef: RefObject<HTMLDivElement | null>;
|
||||
backdropVariant: CreateModalBackdropVariant;
|
||||
stepper?: boolean;
|
||||
kebabTriggerAriaLabel?: string;
|
||||
kebabMenuAriaLabel?: string;
|
||||
kebabMenuItems?: ModalHeaderMenuItem[];
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ export function CreateView({
|
||||
overlayRef,
|
||||
backdropVariant,
|
||||
stepper,
|
||||
kebabTriggerAriaLabel,
|
||||
kebabMenuAriaLabel,
|
||||
kebabMenuItems,
|
||||
}: CreateViewProps) {
|
||||
return (
|
||||
<CreateModalFrameView
|
||||
@@ -42,7 +45,13 @@ export function CreateView({
|
||||
overlayRef={overlayRef}
|
||||
dialogRef={createRef}
|
||||
>
|
||||
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
|
||||
<ModalHeader
|
||||
onClose={onClose}
|
||||
moreOptionsAriaLabel={kebabTriggerAriaLabel}
|
||||
menuAriaLabel={kebabMenuAriaLabel}
|
||||
menuItems={kebabMenuItems}
|
||||
showMoreOptionsButton={(kebabMenuItems?.length ?? 0) > 0}
|
||||
/>
|
||||
|
||||
{headerContent !== undefined ? (
|
||||
<div className="shrink-0">{headerContent}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { memo, useEffect, useId, useRef, useState } from "react";
|
||||
import { ModalHeaderView } from "./ModalHeader.view";
|
||||
import type { ModalHeaderProps } from "./ModalHeader.types";
|
||||
|
||||
@@ -10,7 +10,55 @@ import type { ModalHeaderProps } from "./ModalHeader.types";
|
||||
* (right) icon buttons.
|
||||
*/
|
||||
const ModalHeaderContainer = memo<ModalHeaderProps>((props) => {
|
||||
return <ModalHeaderView {...props} />;
|
||||
const { menuItems = [] } = props;
|
||||
const hasMenu = menuItems.length > 0;
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuId = useId();
|
||||
const menuWrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen || !hasMenu) return;
|
||||
const onDoc = (event: MouseEvent) => {
|
||||
if (
|
||||
menuWrapRef.current &&
|
||||
!menuWrapRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [hasMenu, menuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen || !hasMenu) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [hasMenu, menuOpen]);
|
||||
|
||||
return (
|
||||
<div ref={menuWrapRef}>
|
||||
<ModalHeaderView
|
||||
{...props}
|
||||
menuId={menuId}
|
||||
menuOpen={menuOpen}
|
||||
onToggleMenu={hasMenu ? () => setMenuOpen((open) => !open) : undefined}
|
||||
onMenuItemClick={
|
||||
hasMenu
|
||||
? (item) => {
|
||||
item.onClick?.();
|
||||
setMenuOpen(false);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ModalHeaderContainer.displayName = "ModalHeader";
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import type { IconName } from "../../asset/icon";
|
||||
|
||||
export interface ModalHeaderMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
leadingIcon: IconName;
|
||||
onClick?: () => void;
|
||||
/** Kebab rows only; omit for default lockup styling. */
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
|
||||
export interface ModalHeaderProps {
|
||||
onClose?: () => void;
|
||||
onMoreOptions?: () => void;
|
||||
@@ -7,5 +18,11 @@ export interface ModalHeaderProps {
|
||||
closeButtonAriaLabel?: string;
|
||||
/** When set, used for the more-options control’s accessible name (e.g. localized). */
|
||||
moreOptionsAriaLabel?: string;
|
||||
menuAriaLabel?: string;
|
||||
menuItems?: ModalHeaderMenuItem[];
|
||||
menuId?: string;
|
||||
menuOpen?: boolean;
|
||||
onToggleMenu?: () => void;
|
||||
onMenuItemClick?: (_item: ModalHeaderMenuItem) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import ListItem from "../../layout/ListItem";
|
||||
import Popover from "../Popover";
|
||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||
import type { ModalHeaderProps } from "./ModalHeader.types";
|
||||
|
||||
@@ -11,8 +13,16 @@ export function ModalHeaderView({
|
||||
showMoreOptionsButton = true,
|
||||
closeButtonAriaLabel = "Close dialog",
|
||||
moreOptionsAriaLabel = "More options",
|
||||
menuAriaLabel = "More options menu",
|
||||
menuItems = [],
|
||||
menuId,
|
||||
menuOpen = false,
|
||||
onToggleMenu,
|
||||
onMenuItemClick,
|
||||
className = "",
|
||||
}: ModalHeaderProps) {
|
||||
const hasMenu = menuItems.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-b border-[var(--color-border-default-secondary)] h-[48px] shrink-0 sticky top-0 bg-[var(--color-surface-default-primary)] z-[2] ${className}`}
|
||||
@@ -41,9 +51,12 @@ export function ModalHeaderView({
|
||||
{showMoreOptionsButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoreOptions}
|
||||
onClick={hasMenu ? onToggleMenu : onMoreOptions}
|
||||
className={`${iconButtonClass} right-[24px] top-[12px]`}
|
||||
aria-label={moreOptionsAriaLabel}
|
||||
aria-haspopup={hasMenu ? "menu" : undefined}
|
||||
aria-expanded={hasMenu ? menuOpen : undefined}
|
||||
aria-controls={hasMenu ? menuId : undefined}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
@@ -58,6 +71,22 @@ export function ModalHeaderView({
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{showMoreOptionsButton && hasMenu && menuOpen ? (
|
||||
<div className="absolute right-[24px] top-[44px] z-[300]">
|
||||
<Popover id={menuId} menuAriaLabel={menuAriaLabel}>
|
||||
{menuItems.map((item, index) => (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
showDivider={index < menuItems.length - 1}
|
||||
leadingIcon={item.leadingIcon}
|
||||
label={item.label}
|
||||
variant={item.variant}
|
||||
onClick={() => onMenuItemClick?.(item)}
|
||||
/>
|
||||
))}
|
||||
</Popover>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,12 +30,44 @@ export function applyFinalReviewChipEditPatch(
|
||||
current && typeof current === "object"
|
||||
? (current as Record<string, unknown>)
|
||||
: {};
|
||||
const snapshotLabelPatch =
|
||||
patch.groupKey === "coreValues" &&
|
||||
"chipLabel" in patch &&
|
||||
typeof patch.chipLabel === "string"
|
||||
? (() => {
|
||||
const trim = patch.chipLabel.trim();
|
||||
if (trim.length === 0) {
|
||||
return {} as Partial<CreateFlowState>;
|
||||
}
|
||||
const snap = [...(state.coreValuesChipsSnapshot ?? [])];
|
||||
const i = snap.findIndex((r) => r.id === patch.overrideKey);
|
||||
if (i < 0) {
|
||||
return {} as Partial<CreateFlowState>;
|
||||
}
|
||||
snap[i] = { ...snap[i], label: trim };
|
||||
return {
|
||||
coreValuesChipsSnapshot: snap,
|
||||
} satisfies Partial<CreateFlowState>;
|
||||
})()
|
||||
: ({} as Partial<CreateFlowState>);
|
||||
const detailPatch: Partial<CreateFlowState> = {
|
||||
[stateKey]: {
|
||||
...record,
|
||||
[patch.overrideKey]: patch.value,
|
||||
},
|
||||
...snapshotLabelPatch,
|
||||
};
|
||||
const metaFromPatch =
|
||||
patch.groupKey !== "coreValues" &&
|
||||
"methodCardMeta" in patch &&
|
||||
patch.methodCardMeta !== undefined
|
||||
? {
|
||||
customMethodCardMetaById: {
|
||||
...(state.customMethodCardMetaById ?? {}),
|
||||
[patch.overrideKey]: patch.methodCardMeta,
|
||||
} satisfies NonNullable<CreateFlowState["customMethodCardMetaById"]>,
|
||||
}
|
||||
: {};
|
||||
if (
|
||||
patch.groupKey !== "coreValues" &&
|
||||
"customMethodCardFieldBlocks" in patch &&
|
||||
@@ -43,11 +75,15 @@ export function applyFinalReviewChipEditPatch(
|
||||
) {
|
||||
return {
|
||||
...detailPatch,
|
||||
...metaFromPatch,
|
||||
customMethodCardFieldBlocksById: {
|
||||
...(state.customMethodCardFieldBlocksById ?? {}),
|
||||
[patch.overrideKey]: patch.customMethodCardFieldBlocks,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (Object.keys(metaFromPatch).length > 0) {
|
||||
return { ...detailPatch, ...metaFromPatch };
|
||||
}
|
||||
return detailPatch;
|
||||
}
|
||||
|
||||
@@ -109,6 +109,33 @@ function methodsForGroup(
|
||||
return readMethodPresetsForFacetGroup(groupKey);
|
||||
}
|
||||
|
||||
function selectedMethodIdsForGroup(
|
||||
state: CreateFlowState,
|
||||
groupKey: TemplateFacetGroupKey,
|
||||
): string[] | undefined {
|
||||
switch (groupKey) {
|
||||
case "communication":
|
||||
return state.selectedCommunicationMethodIds;
|
||||
case "membership":
|
||||
return state.selectedMembershipMethodIds;
|
||||
case "decisionApproaches":
|
||||
return state.selectedDecisionApproachIds;
|
||||
case "conflictManagement":
|
||||
return state.selectedConflictManagementIds;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors {@link buildPublishPayload}'s `pickMethodIds` — state wins when set. */
|
||||
function pickMethodIdsForReview(
|
||||
fromState: string[] | undefined,
|
||||
derived: readonly string[],
|
||||
): string[] {
|
||||
if (fromState && fromState.length > 0) return [...fromState];
|
||||
return [...derived];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a preset method id from a chip label (template sections / display
|
||||
* enrichment where entries carry titles but not stable ids).
|
||||
@@ -161,18 +188,44 @@ export function buildFinalReviewCategoryRowsDetailed(
|
||||
if (groupKey === "coreValues" && coreValueEntries.length > 0) continue;
|
||||
const methods = methodsForGroup(groupKey);
|
||||
const entries: FinalReviewChipEntry[] = [];
|
||||
for (const e of s.entries) {
|
||||
const title = e.title.trim();
|
||||
if (title.length === 0) continue;
|
||||
// For the Values section inside template bodies we can't recover a
|
||||
// stable chip id (no snapshot), so override is unavailable — the
|
||||
// modal will render read-only. Method sections fall back to label
|
||||
// → preset-id resolution so matching titles stay editable.
|
||||
let overrideKey: string | null = null;
|
||||
if (groupKey && groupKey !== "coreValues") {
|
||||
overrideKey = overrideKeyForLabel(title, methods);
|
||||
|
||||
if (groupKey && groupKey !== "coreValues") {
|
||||
const stateSel = selectedMethodIdsForGroup(state, groupKey);
|
||||
const derivedIds: string[] = [];
|
||||
for (const e of s.entries) {
|
||||
const title = typeof e.title === "string" ? e.title.trim() : "";
|
||||
if (title.length === 0) continue;
|
||||
const id = resolveMethodPresetIdFromLabel(title, groupKey);
|
||||
if (id) derivedIds.push(id);
|
||||
}
|
||||
// Customize flow keeps `sections` from the template but writes real
|
||||
// facet picks into `selected*MethodIds` (including custom UUID cards).
|
||||
// Match publish (`pickMethodIds`): when those ids exist, drive chips
|
||||
// from state + `customMethodCardMetaById`, not from section titles alone.
|
||||
if (stateSel && stateSel.length > 0) {
|
||||
const ids = pickMethodIdsForReview(stateSel, derivedIds);
|
||||
entries.push(
|
||||
...entriesFromIds(
|
||||
ids,
|
||||
methods,
|
||||
groupKey,
|
||||
state.customMethodCardMetaById,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
for (const e of s.entries) {
|
||||
const title = typeof e.title === "string" ? e.title.trim() : "";
|
||||
if (title.length === 0) continue;
|
||||
const overrideKey = overrideKeyForLabel(title, methods);
|
||||
entries.push({ label: title, groupKey, overrideKey });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const e of s.entries) {
|
||||
const title = typeof e.title === "string" ? e.title.trim() : "";
|
||||
if (title.length === 0) continue;
|
||||
entries.push({ label: title, groupKey, overrideKey: null });
|
||||
}
|
||||
entries.push({ label: title, groupKey, overrideKey });
|
||||
}
|
||||
if (entries.length === 0) continue;
|
||||
rows.push({ name: s.categoryName, groupKey, entries });
|
||||
@@ -230,11 +283,11 @@ export function buildFinalReviewCategoryRowsDetailed(
|
||||
* Derive the final-review Rule category rows from the current
|
||||
* {@link CreateFlowState}.
|
||||
*
|
||||
* Two-mode contract, mirroring the two template entry points:
|
||||
* Contract across template + customize paths:
|
||||
* 1. **Use without changes** — `state.sections` carries the applied template
|
||||
* body; we render it verbatim (`categoryName` + entry `title`s). Core
|
||||
* values still come from `buildCoreValuesForDocument` when they were
|
||||
* captured separately.
|
||||
* body; method facets render from section titles when `selected*MethodIds`
|
||||
* were cleared (see `stripCustomRuleSelectionFields`). Core values still
|
||||
* come from `buildCoreValuesForDocument` when captured separately.
|
||||
* 2. **Customize / plain custom-rule flow** — each Create Custom screen writes
|
||||
* its selection ids into a dedicated state field. We resolve those ids
|
||||
* against the curated message `methods[]` list to get the display labels,
|
||||
|
||||
@@ -153,7 +153,11 @@ export function buildPublishPayload(
|
||||
const methodSelections = buildMethodSelectionsForDocument(state);
|
||||
|
||||
if (hasAnyMethodSelection(methodSelections)) {
|
||||
sections = replaceMethodSectionsWithMethodSelections(sections, methodSelections);
|
||||
sections = replaceMethodSectionsWithMethodSelections(
|
||||
sections,
|
||||
methodSelections,
|
||||
state.customMethodCardFieldBlocksById,
|
||||
);
|
||||
}
|
||||
|
||||
const document: Record<string, unknown> = { sections, coreValues };
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
CommunityStructureChipSnapshotRow,
|
||||
CreateFlowState,
|
||||
} from "../../app/(app)/create/types";
|
||||
import {
|
||||
duplicateMethodCardTitle,
|
||||
omitIdFromStringRecord,
|
||||
} from "./duplicateMethodCardModalDraft";
|
||||
import { moveFacetSelectionIdToFront } from "./methodCardSelectionOrder";
|
||||
|
||||
export const MAX_SELECTED_CORE_VALUES = 5;
|
||||
|
||||
/** Remove a chip from snapshot, selection ids, and per-chip detail overrides. */
|
||||
export function removeCoreValueChipFromDraft(
|
||||
state: CreateFlowState,
|
||||
chipId: string,
|
||||
): Partial<CreateFlowState> {
|
||||
const snap = state.coreValuesChipsSnapshot ?? [];
|
||||
const nextSnap = snap.filter((r) => r.id !== chipId);
|
||||
const sel = [...(state.selectedCoreValueIds ?? [])].filter((id) => id !== chipId);
|
||||
const hadDetail =
|
||||
Boolean(state.coreValueDetailsByChipId) &&
|
||||
Object.prototype.hasOwnProperty.call(state.coreValueDetailsByChipId, chipId);
|
||||
const nextDetails = hadDetail
|
||||
? omitIdFromStringRecord(state.coreValueDetailsByChipId, chipId)
|
||||
: undefined;
|
||||
|
||||
const out: Partial<CreateFlowState> = {
|
||||
coreValuesChipsSnapshot: nextSnap,
|
||||
selectedCoreValueIds: sel,
|
||||
};
|
||||
|
||||
if (hadDetail) {
|
||||
out.coreValueDetailsByChipId = nextDetails;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Clone a core value chip with a suffixed label; returns null when at capacity. */
|
||||
export function duplicateCoreValueChipInDraft(
|
||||
state: CreateFlowState,
|
||||
chipId: string,
|
||||
duplicateTitleSuffix: string,
|
||||
): {
|
||||
patch: Partial<CreateFlowState>;
|
||||
newId: string;
|
||||
newLabel: string;
|
||||
} | null {
|
||||
const sel = [...(state.selectedCoreValueIds ?? [])];
|
||||
if (sel.length >= MAX_SELECTED_CORE_VALUES) {
|
||||
return null;
|
||||
}
|
||||
const snap = state.coreValuesChipsSnapshot ?? [];
|
||||
const row = snap.find((r) => r.id === chipId);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const rawLabel =
|
||||
typeof row.label === "string" && row.label.trim().length > 0
|
||||
? row.label.trim()
|
||||
: chipId;
|
||||
const newId = crypto.randomUUID();
|
||||
const newLabel = duplicateMethodCardTitle(rawLabel, duplicateTitleSuffix);
|
||||
const newRow: CommunityStructureChipSnapshotRow = {
|
||||
id: newId,
|
||||
label: newLabel,
|
||||
state: "selected",
|
||||
};
|
||||
|
||||
const nextSnap = [...snap, newRow];
|
||||
const inherited = state.coreValueDetailsByChipId?.[chipId];
|
||||
const nextDetails =
|
||||
inherited !== undefined
|
||||
? {
|
||||
...(state.coreValueDetailsByChipId ?? {}),
|
||||
[newId]: structuredClone(inherited),
|
||||
}
|
||||
: { ...(state.coreValueDetailsByChipId ?? {}) };
|
||||
|
||||
return {
|
||||
newId,
|
||||
newLabel,
|
||||
patch: {
|
||||
coreValuesChipsSnapshot: nextSnap,
|
||||
selectedCoreValueIds: moveFacetSelectionIdToFront(sel, newId),
|
||||
...(Object.keys(nextDetails).length > 0
|
||||
? { coreValueDetailsByChipId: nextDetails }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
|
||||
|
||||
/**
|
||||
* Localized label for a duplicated method card. Supports a "%s" placeholder or
|
||||
* a suffix appended to the base label (e.g. `" (copy)"`).
|
||||
*/
|
||||
export function duplicateMethodCardTitle(
|
||||
baseLabel: string,
|
||||
duplicateTitleSuffix: string,
|
||||
): string {
|
||||
if (duplicateTitleSuffix.includes("%s")) {
|
||||
return duplicateTitleSuffix.replaceAll("%s", baseLabel);
|
||||
}
|
||||
return `${baseLabel}${duplicateTitleSuffix}`;
|
||||
}
|
||||
|
||||
export function omitIdFromStringRecord<V>(
|
||||
record: Record<string, V> | undefined,
|
||||
id: string,
|
||||
): Record<string, V> | undefined {
|
||||
if (!record || !(id in record)) {
|
||||
return record;
|
||||
}
|
||||
const next: Record<string, V> = { ...record };
|
||||
delete next[id];
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
/** Prefer in-modal draft, then persisted facet entry; deep-clone for state writes. */
|
||||
export function cloneMethodCardDetailsForDuplicate<T>(
|
||||
pendingDraft: T | null,
|
||||
persisted: T | undefined,
|
||||
fallback: () => T,
|
||||
): T {
|
||||
const base = pendingDraft ?? persisted;
|
||||
if (base === undefined || base === null) {
|
||||
return structuredClone(fallback());
|
||||
}
|
||||
return structuredClone(base);
|
||||
}
|
||||
|
||||
export function cloneMethodCardBlocksForDuplicate(
|
||||
blocksById: Record<string, CustomMethodCardFieldBlock[]> | undefined,
|
||||
sourceId: string,
|
||||
): CustomMethodCardFieldBlock[] {
|
||||
return structuredClone(blocksById?.[sourceId] ?? []);
|
||||
}
|
||||
|
||||
/** Shallow-copy facet maps and drop `omitId` if set (chained duplicate of staged card). */
|
||||
export function forkMethodCardFacetMapsForDuplicate<TDetail>(params: {
|
||||
customMethodCardMetaById:
|
||||
| Record<string, { label: string; supportText: string }>
|
||||
| undefined;
|
||||
facetDetailsById: Record<string, TDetail> | undefined;
|
||||
customMethodCardFieldBlocksById:
|
||||
| Record<string, CustomMethodCardFieldBlock[]>
|
||||
| undefined;
|
||||
omitId: string | null;
|
||||
}): {
|
||||
customMethodCardMetaById: Record<string, { label: string; supportText: string }>;
|
||||
facetDetailsById: Record<string, TDetail>;
|
||||
customMethodCardFieldBlocksById: Record<string, CustomMethodCardFieldBlock[]>;
|
||||
} {
|
||||
const customMethodCardMetaById = {
|
||||
...(params.customMethodCardMetaById ?? {}),
|
||||
};
|
||||
const facetDetailsById = { ...(params.facetDetailsById ?? {}) };
|
||||
const customMethodCardFieldBlocksById = {
|
||||
...(params.customMethodCardFieldBlocksById ?? {}),
|
||||
};
|
||||
if (params.omitId) {
|
||||
delete customMethodCardMetaById[params.omitId];
|
||||
delete facetDetailsById[params.omitId];
|
||||
delete customMethodCardFieldBlocksById[params.omitId];
|
||||
}
|
||||
return {
|
||||
customMethodCardMetaById,
|
||||
facetDetailsById,
|
||||
customMethodCardFieldBlocksById,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
|
||||
/**
|
||||
* User-authored method cards (UUID ids) register a meta row when finalized
|
||||
* from {@link CustomMethodCardWizard}. Preset rows from `methods[]` never
|
||||
* appear here — keeps edit surfaces from treating custom ids like presets.
|
||||
* True when `customMethodCardMetaById` has an entry for this id: wizard-finalized
|
||||
* custom UUIDs, duplicate prefab clones, and **preset display overrides** after the
|
||||
* user saves title/description in Customize mode (see {@link mergePresetMethodsWithCustom}).
|
||||
*/
|
||||
export function isCustomMethodCardId(
|
||||
methodId: string,
|
||||
|
||||
@@ -13,6 +13,15 @@ export function mergePresetMethodsWithCustom<
|
||||
meta: Record<string, { label: string; supportText: string }> | undefined,
|
||||
): T[] {
|
||||
const presetIds = new Set(presets.map((p) => p.id));
|
||||
const presetRows = presets.map((p) => {
|
||||
const row = meta?.[p.id];
|
||||
if (!row) return p;
|
||||
return {
|
||||
...p,
|
||||
label: row.label,
|
||||
supportText: row.supportText,
|
||||
} as T;
|
||||
});
|
||||
const customRows: T[] = [];
|
||||
const seenCustom = new Set<string>();
|
||||
|
||||
@@ -28,5 +37,5 @@ export function mergePresetMethodsWithCustom<
|
||||
} as T);
|
||||
}
|
||||
|
||||
return [...presets, ...customRows];
|
||||
return [...presetRows, ...customRows];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
import type { MethodCardHeaderDraft } from "./methodCardCustomizeSession";
|
||||
|
||||
/**
|
||||
* Merges edited customize header strings into persisted method-card meta.
|
||||
*/
|
||||
export function methodCardMetaWithCustomizeHeader(
|
||||
existing: CreateFlowState["customMethodCardMetaById"],
|
||||
pendingCardId: string,
|
||||
header: MethodCardHeaderDraft,
|
||||
): NonNullable<CreateFlowState["customMethodCardMetaById"]> {
|
||||
return {
|
||||
...(existing ?? {}),
|
||||
[pendingCardId]: {
|
||||
label: header.title,
|
||||
supportText: header.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
|
||||
|
||||
export type MethodCardHeaderDraft = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
/** Snapshot of modal-local edits taken when the user enters Customize mode. */
|
||||
export type MethodCardCustomizeSnapshot<TDraft> = {
|
||||
pendingDraft: TDraft;
|
||||
fieldBlocks: CustomMethodCardFieldBlock[] | null;
|
||||
headerDraft: MethodCardHeaderDraft;
|
||||
};
|
||||
|
||||
export function captureMethodCardCustomizeSnapshot<TDraft>(
|
||||
pendingDraft: TDraft,
|
||||
fieldBlocks: CustomMethodCardFieldBlock[] | null,
|
||||
headerDraft: MethodCardHeaderDraft,
|
||||
): MethodCardCustomizeSnapshot<TDraft> {
|
||||
return {
|
||||
pendingDraft: structuredClone(pendingDraft),
|
||||
fieldBlocks:
|
||||
fieldBlocks === null ? null : structuredClone(fieldBlocks),
|
||||
headerDraft: { ...headerDraft },
|
||||
};
|
||||
}
|
||||
|
||||
export function isMethodCardCustomizeSessionDirty<TDraft>(
|
||||
snapshot: MethodCardCustomizeSnapshot<TDraft>,
|
||||
pendingDraft: TDraft | null,
|
||||
draftFieldBlocks: CustomMethodCardFieldBlock[] | null,
|
||||
headerDraft: MethodCardHeaderDraft | null,
|
||||
): boolean {
|
||||
if (!pendingDraft) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
JSON.stringify(pendingDraft) !== JSON.stringify(snapshot.pendingDraft)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (headerDraft !== null) {
|
||||
if (
|
||||
headerDraft.title !== snapshot.headerDraft.title ||
|
||||
headerDraft.description !== snapshot.headerDraft.description
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const cur =
|
||||
draftFieldBlocks === null ? null : JSON.stringify(draftFieldBlocks);
|
||||
const snap =
|
||||
snapshot.fieldBlocks === null ? null : JSON.stringify(snapshot.fieldBlocks);
|
||||
return cur !== snap;
|
||||
}
|
||||
|
||||
/** For Close / overlay / Escape — skip closing when user cancels the confirm. */
|
||||
export function confirmDiscardMethodCardCustomizeSession<TDraft>(
|
||||
modalEditUnlocked: boolean,
|
||||
snapshot: MethodCardCustomizeSnapshot<TDraft> | null,
|
||||
pendingDraft: TDraft | null,
|
||||
draftFieldBlocks: CustomMethodCardFieldBlock[] | null,
|
||||
headerDraft: MethodCardHeaderDraft | null,
|
||||
message: string,
|
||||
): boolean {
|
||||
if (!modalEditUnlocked || snapshot === null) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
!isMethodCardCustomizeSessionDirty(
|
||||
snapshot,
|
||||
pendingDraft,
|
||||
draftFieldBlocks,
|
||||
headerDraft,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return window.confirm(message);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
CommunicationMethodDetailEntry,
|
||||
ConflictManagementDetailEntry,
|
||||
DecisionApproachDetailEntry,
|
||||
MembershipMethodDetailEntry,
|
||||
} from "../../app/(app)/create/types";
|
||||
import {
|
||||
communicationPresetFor,
|
||||
conflictManagementPresetFor,
|
||||
decisionApproachPresetFor,
|
||||
membershipPresetFor,
|
||||
} from "./finalReviewChipPresets";
|
||||
|
||||
function stringArraysEqual(a: readonly string[], b: readonly string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((v, i) => v === b[i]);
|
||||
}
|
||||
|
||||
/** True when communication facet text matches {@link communicationPresetFor} for this card id. */
|
||||
export function communicationMethodFacetMatchesPreset(
|
||||
details: CommunicationMethodDetailEntry | undefined,
|
||||
cardId: string,
|
||||
): boolean {
|
||||
if (!details) return true;
|
||||
const p = communicationPresetFor(cardId);
|
||||
return (
|
||||
details.corePrinciple === p.corePrinciple &&
|
||||
details.logisticsAdmin === p.logisticsAdmin &&
|
||||
details.codeOfConduct === p.codeOfConduct
|
||||
);
|
||||
}
|
||||
|
||||
export function membershipMethodFacetMatchesPreset(
|
||||
details: MembershipMethodDetailEntry | undefined,
|
||||
cardId: string,
|
||||
): boolean {
|
||||
if (!details) return true;
|
||||
const p = membershipPresetFor(cardId);
|
||||
return (
|
||||
details.eligibility === p.eligibility &&
|
||||
details.joiningProcess === p.joiningProcess &&
|
||||
details.expectations === p.expectations
|
||||
);
|
||||
}
|
||||
|
||||
export function decisionApproachFacetMatchesPreset(
|
||||
details: DecisionApproachDetailEntry | undefined,
|
||||
cardId: string,
|
||||
): boolean {
|
||||
if (!details) return true;
|
||||
const p = decisionApproachPresetFor(cardId);
|
||||
return (
|
||||
details.corePrinciple === p.corePrinciple &&
|
||||
stringArraysEqual(details.applicableScope, p.applicableScope) &&
|
||||
stringArraysEqual(details.selectedApplicableScope, p.selectedApplicableScope) &&
|
||||
details.stepByStepInstructions === p.stepByStepInstructions &&
|
||||
details.consensusLevel === p.consensusLevel &&
|
||||
details.objectionsDeadlocks === p.objectionsDeadlocks
|
||||
);
|
||||
}
|
||||
|
||||
export function conflictManagementFacetMatchesPreset(
|
||||
details: ConflictManagementDetailEntry | undefined,
|
||||
cardId: string,
|
||||
): boolean {
|
||||
if (!details) return true;
|
||||
const p = conflictManagementPresetFor(cardId);
|
||||
return (
|
||||
details.corePrinciple === p.corePrinciple &&
|
||||
stringArraysEqual(details.applicableScope, p.applicableScope) &&
|
||||
stringArraysEqual(details.selectedApplicableScope, p.selectedApplicableScope) &&
|
||||
details.processProtocol === p.processProtocol &&
|
||||
details.restorationFallbacks === p.restorationFallbacks
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,38 @@ import {
|
||||
} from "./customRuleFacets";
|
||||
import type { PublishedMethodSelections } from "./buildPublishPayload";
|
||||
import type { StoredLastPublishedRule } from "./lastPublishedRule";
|
||||
import { methodLabelFor } from "./finalReviewChipPresets";
|
||||
import type { TemplateFacetGroupKey } from "./templateReviewMapping";
|
||||
|
||||
function customMethodCardMetaFromPublishedSelections(
|
||||
ms: PublishedMethodSelections,
|
||||
): CreateFlowState["customMethodCardMetaById"] | undefined {
|
||||
const meta: NonNullable<CreateFlowState["customMethodCardMetaById"]> = {};
|
||||
const absorb = (
|
||||
groupKey: TemplateFacetGroupKey,
|
||||
rows:
|
||||
| Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
}>
|
||||
| undefined,
|
||||
) => {
|
||||
if (!rows) return;
|
||||
for (const row of rows) {
|
||||
const id = typeof row.id === "string" ? row.id.trim() : "";
|
||||
if (!id) continue;
|
||||
if (methodLabelFor(groupKey, id).length > 0) continue;
|
||||
const label = typeof row.label === "string" ? row.label.trim() : "";
|
||||
if (!label) continue;
|
||||
meta[id] = { label, supportText: "" };
|
||||
}
|
||||
};
|
||||
absorb("communication", ms.communication);
|
||||
absorb("membership", ms.membership);
|
||||
absorb("decisionApproaches", ms.decisionApproaches);
|
||||
absorb("conflictManagement", ms.conflictManagement);
|
||||
return Object.keys(meta).length > 0 ? meta : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `patch` (from {@link createFlowStateFromPublishedRule}) expects
|
||||
@@ -31,6 +63,26 @@ export function isPublishedRuleSelectionMissing(
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when published-rule hydration should run (or continue) — facet ids still
|
||||
* empty, or {@link createFlowStateFromPublishedRule} produced
|
||||
* `customMethodCardMetaById` for user-authored method UUIDs that `state` does
|
||||
* not have yet (final-review chips use meta + id when no preset label exists).
|
||||
*/
|
||||
export function isPublishedRuleHydratePatchIncomplete(
|
||||
state: CreateFlowState,
|
||||
patch: Partial<CreateFlowState>,
|
||||
): boolean {
|
||||
if (isPublishedRuleSelectionMissing(state, patch)) return true;
|
||||
const pm = patch.customMethodCardMetaById;
|
||||
if (!pm || Object.keys(pm).length === 0) return false;
|
||||
const sm = state.customMethodCardMetaById ?? {};
|
||||
for (const key of Object.keys(pm)) {
|
||||
if (!sm[key]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin flags for method-card facets: persisted for hydration and footer Confirm.
|
||||
* Card Stack display pulls selections to the top whenever `selected*` ids are
|
||||
@@ -171,6 +223,11 @@ export function createFlowStateFromPublishedRule(
|
||||
);
|
||||
}
|
||||
|
||||
const customMeta = customMethodCardMetaFromPublishedSelections(ms);
|
||||
if (customMeta) {
|
||||
out.customMethodCardMetaById = customMeta;
|
||||
}
|
||||
|
||||
/** Drop template `sections` so final-review uses `methodSelections` / selected ids (edit path). */
|
||||
out.sections = [];
|
||||
return out;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CommunityRuleSection,
|
||||
} from "../../app/components/type/CommunityRule/CommunityRule.types";
|
||||
import type { PublishedMethodSelections } from "./buildPublishPayload";
|
||||
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
|
||||
import {
|
||||
PUBLISH_FALLBACK_OVERVIEW_CATEGORY,
|
||||
parseDocumentSectionsForDisplay,
|
||||
@@ -221,6 +222,14 @@ function parseMethodSelectionsLoose(
|
||||
return ms as PublishedMethodSelections;
|
||||
}
|
||||
|
||||
function parseCustomFieldBlocksByIdLoose(
|
||||
document: Record<string, unknown>,
|
||||
): Record<string, CustomMethodCardFieldBlock[]> | undefined {
|
||||
const raw = document.customMethodCardFieldBlocksById;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
return raw as Record<string, CustomMethodCardFieldBlock[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full `CommunityRule` sections for a published `document` JSON blob: validated
|
||||
* `document.sections` plus synthesized categories from `document.coreValues` and
|
||||
@@ -253,30 +262,49 @@ export function parsePublishedDocumentForCommunityRuleDisplay(
|
||||
seen.add(valuesSection.categoryName);
|
||||
}
|
||||
|
||||
let displaySections = [...base, ...extra];
|
||||
|
||||
const methodSelections = parseMethodSelectionsLoose(doc);
|
||||
const customFieldBlocksById = parseCustomFieldBlocksByIdLoose(doc);
|
||||
if (methodSelections) {
|
||||
const comm = sectionFromCommunication(methodSelections.communication ?? []);
|
||||
if (comm && !seen.has(comm.categoryName)) {
|
||||
extra.push(comm);
|
||||
seen.add(comm.categoryName);
|
||||
}
|
||||
const mem = sectionFromMembership(methodSelections.membership ?? []);
|
||||
if (mem && !seen.has(mem.categoryName)) {
|
||||
extra.push(mem);
|
||||
seen.add(mem.categoryName);
|
||||
}
|
||||
const dec = sectionFromDecision(methodSelections.decisionApproaches ?? []);
|
||||
if (dec && !seen.has(dec.categoryName)) {
|
||||
extra.push(dec);
|
||||
seen.add(dec.categoryName);
|
||||
}
|
||||
const cm = sectionFromConflict(methodSelections.conflictManagement ?? []);
|
||||
if (cm && !seen.has(cm.categoryName)) {
|
||||
extra.push(cm);
|
||||
seen.add(cm.categoryName);
|
||||
}
|
||||
/**
|
||||
* `document.sections` can lag `document.methodSelections` (e.g. API responses
|
||||
* or older rows). Do not skip merging when the category already exists —
|
||||
* that hid user-authored method cards on `/create/completed`.
|
||||
*/
|
||||
const replaceCategory = (fresh: CommunityRuleSection | null) => {
|
||||
if (!fresh) return;
|
||||
displaySections = displaySections.filter(
|
||||
(s) => s.categoryName !== fresh.categoryName,
|
||||
);
|
||||
displaySections.push(fresh);
|
||||
};
|
||||
replaceCategory(
|
||||
sectionFromCommunication(
|
||||
methodSelections.communication ?? [],
|
||||
customFieldBlocksById,
|
||||
),
|
||||
);
|
||||
replaceCategory(
|
||||
sectionFromMembership(
|
||||
methodSelections.membership ?? [],
|
||||
customFieldBlocksById,
|
||||
),
|
||||
);
|
||||
replaceCategory(
|
||||
sectionFromDecision(
|
||||
methodSelections.decisionApproaches ?? [],
|
||||
customFieldBlocksById,
|
||||
),
|
||||
);
|
||||
replaceCategory(
|
||||
sectionFromConflict(
|
||||
methodSelections.conflictManagement ?? [],
|
||||
customFieldBlocksById,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const combined = [...base, ...extra].map(enrichDisplaySection);
|
||||
const combined = displaySections.map(enrichDisplaySection);
|
||||
return sortSectionsCanonical(combined);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,56 @@ import type {
|
||||
CommunityRuleSection,
|
||||
} from "../../app/components/type/CommunityRule/CommunityRule.types";
|
||||
import type { PublishedMethodSelections } from "./buildPublishPayload";
|
||||
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
|
||||
import { templateCategoryToGroupKey } from "./templateReviewMapping";
|
||||
|
||||
/**
|
||||
* Serialize wizard-authored field blocks into Community Rule labeled rows for
|
||||
* read-only surfaces (completed step, exported views). Matches how those blocks
|
||||
* are edited in-app; `placeholderText` holds the author's answer for text blocks.
|
||||
*/
|
||||
export function labeledBlocksFromCustomMethodCardFieldBlocks(
|
||||
blocks: CustomMethodCardFieldBlock[],
|
||||
): CommunityRuleLabeledBlock[] {
|
||||
const out: CommunityRuleLabeledBlock[] = [];
|
||||
for (const b of blocks) {
|
||||
switch (b.kind) {
|
||||
case "text": {
|
||||
const body = nonEmptyTrimmed(b.placeholderText);
|
||||
if (body) out.push({ label: b.blockTitle, body });
|
||||
break;
|
||||
}
|
||||
case "badges": {
|
||||
const opts = b.options.filter((x) => typeof x === "string" && x.trim().length > 0);
|
||||
if (opts.length === 0) break;
|
||||
out.push({ label: b.blockTitle, body: opts.join(", ") });
|
||||
break;
|
||||
}
|
||||
case "upload": {
|
||||
const name = nonEmptyTrimmed(b.fileName);
|
||||
const url = nonEmptyTrimmed(b.assetUrl);
|
||||
const body = name ?? url;
|
||||
if (body) out.push({ label: b.blockTitle, body });
|
||||
break;
|
||||
}
|
||||
case "proportion":
|
||||
out.push({
|
||||
label: b.blockTitle,
|
||||
body: `${b.defaultPercent}%`,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type CommunityRuleEntryFromChipOptions = {
|
||||
consensusLevelKey?: string;
|
||||
customFieldBlocks?: CustomMethodCardFieldBlock[];
|
||||
};
|
||||
|
||||
/** Canonical `categoryName` strings for method groups in published documents. */
|
||||
export const RULE_SECTION_CATEGORY = {
|
||||
values: "Values",
|
||||
@@ -107,21 +155,35 @@ export function communityRuleEntryFromMethodChip(
|
||||
title: string,
|
||||
sections: Record<string, unknown>,
|
||||
labelByKey: Record<string, string>,
|
||||
options?: { consensusLevelKey?: string },
|
||||
options?: CommunityRuleEntryFromChipOptions,
|
||||
): CommunityRuleEntry | null {
|
||||
const blocks = blocksFromKeyedRecord(sections, labelByKey, options);
|
||||
const presetBlocks = blocksFromKeyedRecord(
|
||||
sections,
|
||||
labelByKey,
|
||||
options?.consensusLevelKey
|
||||
? { consensusLevelKey: options.consensusLevelKey }
|
||||
: undefined,
|
||||
);
|
||||
const wizardBlocks =
|
||||
options?.customFieldBlocks && options.customFieldBlocks.length > 0
|
||||
? labeledBlocksFromCustomMethodCardFieldBlocks(options.customFieldBlocks)
|
||||
: [];
|
||||
const blocks = [...presetBlocks, ...wizardBlocks];
|
||||
if (blocks.length === 0) return null;
|
||||
return { title, body: "", blocks };
|
||||
}
|
||||
|
||||
export function sectionFromCommunication(
|
||||
ms: NonNullable<PublishedMethodSelections["communication"]>,
|
||||
customFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>,
|
||||
): CommunityRuleSection | null {
|
||||
if (ms.length === 0) return null;
|
||||
const entries: CommunityRuleEntry[] = [];
|
||||
for (const m of ms) {
|
||||
const sec = m.sections as unknown as Record<string, unknown>;
|
||||
const e = communityRuleEntryFromMethodChip(m.label, sec, COMM_LABELS);
|
||||
const e = communityRuleEntryFromMethodChip(m.label, sec, COMM_LABELS, {
|
||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||
});
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
return entries.length > 0
|
||||
@@ -131,12 +193,15 @@ export function sectionFromCommunication(
|
||||
|
||||
export function sectionFromMembership(
|
||||
ms: NonNullable<PublishedMethodSelections["membership"]>,
|
||||
customFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>,
|
||||
): CommunityRuleSection | null {
|
||||
if (ms.length === 0) return null;
|
||||
const entries: CommunityRuleEntry[] = [];
|
||||
for (const m of ms) {
|
||||
const sec = m.sections as unknown as Record<string, unknown>;
|
||||
const e = communityRuleEntryFromMethodChip(m.label, sec, MEM_LABELS);
|
||||
const e = communityRuleEntryFromMethodChip(m.label, sec, MEM_LABELS, {
|
||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||
});
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
return entries.length > 0
|
||||
@@ -146,6 +211,7 @@ export function sectionFromMembership(
|
||||
|
||||
export function sectionFromDecision(
|
||||
ms: NonNullable<PublishedMethodSelections["decisionApproaches"]>,
|
||||
customFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>,
|
||||
): CommunityRuleSection | null {
|
||||
if (ms.length === 0) return null;
|
||||
const entries: CommunityRuleEntry[] = [];
|
||||
@@ -159,6 +225,7 @@ export function sectionFromDecision(
|
||||
delete merged.selectedApplicableScope;
|
||||
const e = communityRuleEntryFromMethodChip(m.label, merged, DEC_LABELS, {
|
||||
consensusLevelKey: "consensusLevel",
|
||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||
});
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
@@ -169,6 +236,7 @@ export function sectionFromDecision(
|
||||
|
||||
export function sectionFromConflict(
|
||||
ms: NonNullable<PublishedMethodSelections["conflictManagement"]>,
|
||||
customFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>,
|
||||
): CommunityRuleSection | null {
|
||||
if (ms.length === 0) return null;
|
||||
const entries: CommunityRuleEntry[] = [];
|
||||
@@ -180,7 +248,9 @@ export function sectionFromConflict(
|
||||
formatScopePayload(sec.applicableScope);
|
||||
if (scope) merged.applicableScope = scope;
|
||||
delete merged.selectedApplicableScope;
|
||||
const e = communityRuleEntryFromMethodChip(m.label, merged, CM_LABELS);
|
||||
const e = communityRuleEntryFromMethodChip(m.label, merged, CM_LABELS, {
|
||||
customFieldBlocks: customFieldBlocksById?.[m.id],
|
||||
});
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
return entries.length > 0
|
||||
@@ -195,20 +265,21 @@ export function sectionFromConflict(
|
||||
export function replaceMethodSectionsWithMethodSelections(
|
||||
sections: CommunityRuleSection[],
|
||||
ms: PublishedMethodSelections,
|
||||
customFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>,
|
||||
): CommunityRuleSection[] {
|
||||
return sections.map((s) => {
|
||||
const gk = templateCategoryToGroupKey(s.categoryName);
|
||||
if (gk === "communication" && ms.communication?.length) {
|
||||
return sectionFromCommunication(ms.communication) ?? s;
|
||||
return sectionFromCommunication(ms.communication, customFieldBlocksById) ?? s;
|
||||
}
|
||||
if (gk === "membership" && ms.membership?.length) {
|
||||
return sectionFromMembership(ms.membership) ?? s;
|
||||
return sectionFromMembership(ms.membership, customFieldBlocksById) ?? s;
|
||||
}
|
||||
if (gk === "decisionApproaches" && ms.decisionApproaches?.length) {
|
||||
return sectionFromDecision(ms.decisionApproaches) ?? s;
|
||||
return sectionFromDecision(ms.decisionApproaches, customFieldBlocksById) ?? s;
|
||||
}
|
||||
if (gk === "conflictManagement" && ms.conflictManagement?.length) {
|
||||
return sectionFromConflict(ms.conflictManagement) ?? s;
|
||||
return sectionFromConflict(ms.conflictManagement, customFieldBlocksById) ?? s;
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
|
||||
import { isCustomMethodCardId } from "./isCustomMethodCardId";
|
||||
|
||||
/**
|
||||
* Create modals use {@link CustomMethodCardModalBody} when there are structured field
|
||||
* blocks for this method id (wizard-finalized cards, final-review chip edits, etc.),
|
||||
* including **proportion-only** layouts.
|
||||
*
|
||||
* Check persisted blocks **before** {@link isCustomMethodCardId}: `meta` can be absent
|
||||
* while `customMethodCardFieldBlocksById[id]` is still populated (e.g. partial merges).
|
||||
*
|
||||
* Duplicating a preset registers `meta` for the clone but leaves blocks empty — those
|
||||
* stubs keep the facet's structured edit fields until the user adds blocks (then this
|
||||
* returns true once persisted blocks are non-empty).
|
||||
*
|
||||
* **View mode** (`modalEditUnlocked` false): when the custom card still has facet copy
|
||||
* that matches preset seeds only (see `./methodCardFacetMatchesPresetForId`), route to
|
||||
* {@link CustomMethodCardModalBody} so meta-only wizard cards show policy copy instead
|
||||
* of empty preset section editors. Pass `customFacetDetailsMatchPreset: false` when the
|
||||
* caller knows facet details were edited or cloned from a filled preset.
|
||||
*/
|
||||
export function usesWizardFieldBlocksModalBody(args: {
|
||||
methodId: string;
|
||||
meta: CreateFlowState["customMethodCardMetaById"];
|
||||
fieldBlocksById: CreateFlowState["customMethodCardFieldBlocksById"];
|
||||
modalEditUnlocked: boolean;
|
||||
draftFieldBlocks: readonly CustomMethodCardFieldBlock[] | null;
|
||||
/** When strictly `true` and modal is read-only, use wizard body for custom cards with empty blocks. */
|
||||
customFacetDetailsMatchPreset?: boolean;
|
||||
}): boolean {
|
||||
const persisted = args.fieldBlocksById?.[args.methodId];
|
||||
if (Array.isArray(persisted) && persisted.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (!isCustomMethodCardId(args.methodId, args.meta)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
args.modalEditUnlocked &&
|
||||
args.draftFieldBlocks !== null &&
|
||||
args.draftFieldBlocks.length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
!args.modalEditUnlocked && args.customFacetDetailsMatchPreset === true
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,8 @@
|
||||
"subtitle": "Edit or add to this description to describe what this value means to your community.",
|
||||
"meaningLabel": "What does this value mean to your group?",
|
||||
"signalsLabel": "Signals of Violation",
|
||||
"addValueButton": "Add Value"
|
||||
"addValueButton": "Add Value",
|
||||
"customizeValueNameLabel": "Value name"
|
||||
},
|
||||
"values": [
|
||||
{
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"finalize": "Finalize"
|
||||
},
|
||||
"editModal": {
|
||||
"noCustomFieldsYet": "No custom fields yet.",
|
||||
"placeholderBody": "This policy uses the title and description you set when you created it. Extra section fields from preset templates are hidden here so you are not shown empty boxes that do not match what you configured.",
|
||||
"readout": {
|
||||
"emptyValue": "—",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"_comment": "Shared kebab popover labels for create custom-rule modals.",
|
||||
"triggerAriaLabel": "More options",
|
||||
"menuAriaLabel": "Custom rule options",
|
||||
"items": {
|
||||
"customize": "Customize",
|
||||
"duplicate": "Duplicate",
|
||||
"remove": "Remove"
|
||||
},
|
||||
"duplicateTitleSuffix": " (copy)",
|
||||
"saveEdits": "Save",
|
||||
"customizePolicyTitleLabel": "Policy title",
|
||||
"customizePolicyDescriptionLabel": "Description",
|
||||
"cancelCustomize": "Cancel",
|
||||
"discardUnsavedCustomizeChanges": "Discard unsaved changes?"
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import createMembership from "./create/customRule/membership.json";
|
||||
import createDecisionApproaches from "./create/customRule/decisionApproaches.json";
|
||||
import createConflictManagement from "./create/customRule/conflictManagement.json";
|
||||
import createCustomMethodCardWizard from "./create/customRule/customMethodCardWizard.json";
|
||||
import createModalKebabMenu from "./create/customRule/modalKebabMenu.json";
|
||||
|
||||
// create – stage 3: reviewAndComplete
|
||||
import createConfirmStakeholders from "./create/reviewAndComplete/confirmStakeholders.json";
|
||||
@@ -97,6 +98,7 @@ export default {
|
||||
decisionApproaches: createDecisionApproaches,
|
||||
conflictManagement: createConflictManagement,
|
||||
customMethodCardWizard: createCustomMethodCardWizard,
|
||||
modalKebabMenu: createModalKebabMenu,
|
||||
},
|
||||
reviewAndComplete: {
|
||||
confirmStakeholders: createConfirmStakeholders,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
@@ -63,17 +63,22 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const textareas = within(dialog).getAllByRole("textbox");
|
||||
expect(textareas.length).toBe(3);
|
||||
// Preset corePrinciple must seed into the first textarea so the user
|
||||
// edits a real starting point rather than an empty field.
|
||||
expect((textareas[0] as HTMLTextAreaElement).value.length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
|
||||
|
||||
fireEvent.change(textareas[0], { target: { value: "Custom principle" } });
|
||||
const textboxes = within(screen.getByRole("dialog")).getAllByRole("textbox");
|
||||
expect(textboxes.length).toBe(5);
|
||||
const corePrincipleField = textboxes[2] as HTMLTextAreaElement;
|
||||
// Preset corePrinciple must seed into the first body textarea so the user
|
||||
// edits a real starting point rather than an empty field.
|
||||
expect(corePrincipleField.value.length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.change(corePrincipleField, { target: { value: "Custom principle" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: "Add Platform" }),
|
||||
within(screen.getByRole("dialog")).getByRole("button", {
|
||||
name: "Add Platform",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -101,11 +106,7 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const [firstTextarea] = within(dialog).getAllByRole("textbox");
|
||||
fireEvent.change(firstTextarea, {
|
||||
target: { value: "Should NOT persist" },
|
||||
});
|
||||
|
||||
void dialog;
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
@@ -143,8 +144,201 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => {
|
||||
const textareas = within(dialog).getAllByRole(
|
||||
"textbox",
|
||||
) as HTMLTextAreaElement[];
|
||||
expect(textareas.length).toBe(3);
|
||||
expect(textareas[0].value).toBe("Saved principle");
|
||||
expect(textareas[1].value).toBe("Saved logistics");
|
||||
expect(textareas[2].value).toBe("Saved coc");
|
||||
});
|
||||
|
||||
it("Cancel customize reverts edited preset without persisting (no confirm when unchanged)", async () => {
|
||||
let latest: CreateFlowState = {};
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockImplementation(() => {
|
||||
throw new Error("confirm should not run when customize session is clean");
|
||||
});
|
||||
render(
|
||||
<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" }));
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(
|
||||
(within(screen.getByRole("dialog")).getAllByRole(
|
||||
"textbox",
|
||||
)[0] as HTMLTextAreaElement).disabled,
|
||||
).toBe(true);
|
||||
expect(latest.communicationMethodDetailsById).toBeUndefined();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("Cancel customize with edits restores snapshot after confirm", async () => {
|
||||
let latest: CreateFlowState = {};
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
render(
|
||||
<ScreenWithStateProbe
|
||||
onState={(s) => {
|
||||
latest = s;
|
||||
}}
|
||||
initial={{
|
||||
selectedCommunicationMethodIds: ["signal"],
|
||||
communicationMethodDetailsById: {
|
||||
signal: {
|
||||
corePrinciple: "Saved principle",
|
||||
logisticsAdmin: "Saved logistics",
|
||||
codeOfConduct: "Saved coc",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0],
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
|
||||
|
||||
const textboxes = within(dialog).getAllByRole(
|
||||
"textbox",
|
||||
) as HTMLTextAreaElement[];
|
||||
fireEvent.change(textboxes[2], { target: { value: "Edited principle" } });
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
expect(
|
||||
(
|
||||
within(screen.getByRole("dialog")).getAllByRole(
|
||||
"textbox",
|
||||
)[0] as HTMLTextAreaElement
|
||||
).value,
|
||||
).toBe("Saved principle");
|
||||
expect(
|
||||
latest.communicationMethodDetailsById?.signal?.corePrinciple,
|
||||
).toBe("Saved principle");
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("dirty Escape close stays open when user declines discard confirm", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
render(
|
||||
<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" });
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("persists customized policy title for a custom UUID card on Save", async () => {
|
||||
const customId = "00000000-0000-4000-8000-0000000000aa";
|
||||
let latest: CreateFlowState = {};
|
||||
render(
|
||||
<ScreenWithStateProbe
|
||||
onState={(s) => {
|
||||
latest = s;
|
||||
}}
|
||||
initial={{
|
||||
selectedCommunicationMethodIds: [customId],
|
||||
customMethodCardMetaById: {
|
||||
[customId]: { label: "Original title", supportText: "Sub" },
|
||||
},
|
||||
communicationMethodDetailsById: {
|
||||
[customId]: {
|
||||
corePrinciple: "p",
|
||||
logisticsAdmin: "l",
|
||||
codeOfConduct: "c",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: /Original title/ })[0],
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
|
||||
|
||||
const titleInput = within(screen.getByRole("dialog")).getAllByRole(
|
||||
"textbox",
|
||||
)[0] as HTMLInputElement;
|
||||
fireEvent.change(titleInput, { target: { value: "Renamed policy" } });
|
||||
fireEvent.click(
|
||||
within(screen.getByRole("dialog")).getByRole("button", { name: "Save" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(latest.customMethodCardMetaById?.[customId]?.label).toBe(
|
||||
"Renamed policy",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("stores preset id title override in customMethodCardMetaById on Save", async () => {
|
||||
let latest: CreateFlowState = {};
|
||||
render(
|
||||
<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 titleInput = within(screen.getByRole("dialog")).getAllByRole(
|
||||
"textbox",
|
||||
)[0] as HTMLInputElement;
|
||||
fireEvent.change(titleInput, {
|
||||
target: { value: "Custom Signal header" },
|
||||
});
|
||||
fireEvent.click(
|
||||
within(screen.getByRole("dialog")).getByRole("button", { name: "Save" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(latest.customMethodCardMetaById?.signal?.label).toBe(
|
||||
"Custom Signal header",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
} from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import CustomMethodCardFieldBlocksSummary from "../../app/(app)/create/components/CustomMethodCardFieldBlocksSummary";
|
||||
import messages from "../../messages/en/index";
|
||||
import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const uploadCopy =
|
||||
messages.create.customRule.customMethodCardWizard.fieldModals.upload;
|
||||
|
||||
describe("CustomMethodCardFieldBlocksSummary", () => {
|
||||
it("hides Upload when an upload block already has assetUrl; shows preview and remove control", () => {
|
||||
const onBlocksChange = vi.fn();
|
||||
render(
|
||||
<CustomMethodCardFieldBlocksSummary
|
||||
blocks={[
|
||||
{
|
||||
kind: "upload",
|
||||
id: "u1",
|
||||
blockTitle: "Attachment",
|
||||
fileName: "photo.png",
|
||||
assetUrl: "/api/uploads/test-id",
|
||||
},
|
||||
]}
|
||||
onBlocksChange={onBlocksChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("img", { name: uploadCopy.uploadPreviewImageAlt }),
|
||||
).toHaveAttribute("src", "/api/uploads/test-id");
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: uploadCopy.clearPendingUploadAriaLabel,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Upload" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("after remove, parent can pass cleared blocks and Upload shows again", () => {
|
||||
function Harness() {
|
||||
const [blocks, setBlocks] = useState<CustomMethodCardFieldBlock[]>([
|
||||
{
|
||||
kind: "upload",
|
||||
id: "u1",
|
||||
blockTitle: "Attachment",
|
||||
fileName: "photo.png",
|
||||
assetUrl: "/api/uploads/test-id",
|
||||
},
|
||||
]);
|
||||
return (
|
||||
<CustomMethodCardFieldBlocksSummary
|
||||
blocks={blocks}
|
||||
onBlocksChange={setBlocks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: uploadCopy.clearPendingUploadAriaLabel,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: uploadCopy.clearPendingUploadAriaLabel,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
cleanup,
|
||||
} from "../utils/test-utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import CustomMethodCardModalBody from "../../app/(app)/create/components/CustomMethodCardModalBody";
|
||||
import messages from "../../messages/en/index";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const wizard = messages.create.customRule.customMethodCardWizard;
|
||||
|
||||
describe("CustomMethodCardModalBody", () => {
|
||||
it("with meta and no blocks, shows policy title, description, and no-fields hint", () => {
|
||||
render(
|
||||
<CustomMethodCardModalBody
|
||||
cardId="c1"
|
||||
blocksById={{}}
|
||||
policyMeta={{ label: "Our policy", supportText: "How we work" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Our policy")).toBeInTheDocument();
|
||||
expect(screen.getByText("How we work")).toBeInTheDocument();
|
||||
expect(screen.getByText(wizard.editModal.noCustomFieldsYet)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("with meta and no blocks in customize mode, omits duplicate ContentLockup but keeps hint", () => {
|
||||
render(
|
||||
<CustomMethodCardModalBody
|
||||
cardId="c1"
|
||||
blocksById={{}}
|
||||
policyMeta={{ label: "T", supportText: "D" }}
|
||||
showPolicyContentLockupWhenNoBlocks={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("T")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("D")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(wizard.editModal.noCustomFieldsYet)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("without meta, falls back to placeholder", () => {
|
||||
render(<CustomMethodCardModalBody cardId="c1" blocksById={{}} />);
|
||||
|
||||
expect(screen.getByText(wizard.editModal.placeholderBody)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, within } from "@testing-library/react";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
@@ -185,6 +185,26 @@ describe("FinalReviewScreen — prefilled selections", () => {
|
||||
});
|
||||
|
||||
describe("FinalReviewScreen — chip detail modal", () => {
|
||||
async function enterMethodCustomizeFromDialog(dialog: HTMLElement) {
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /more options/i }),
|
||||
);
|
||||
const customize = await screen.findByRole("menuitem", {
|
||||
name: /^customize$/i,
|
||||
});
|
||||
fireEvent.click(customize);
|
||||
}
|
||||
|
||||
async function enterCoreValueCustomizeFromDialog(dialog: HTMLElement) {
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /more options/i }),
|
||||
);
|
||||
const customize = await screen.findByRole("menuitem", {
|
||||
name: /^customize$/i,
|
||||
});
|
||||
fireEvent.click(customize);
|
||||
}
|
||||
|
||||
it("opens the read-only detail modal when a chip is clicked, matching the preset copy", async () => {
|
||||
render(<FinalReviewWithCustomizeSelections />);
|
||||
|
||||
@@ -208,6 +228,83 @@ describe("FinalReviewScreen — chip detail modal", () => {
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("method chip modal kebab offers Customize but not Duplicate", async () => {
|
||||
render(<FinalReviewWithCustomizeSelections />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /more options/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /^customize$/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /^duplicate$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("values chip modal kebab offers Customize and Duplicate under the cap", async () => {
|
||||
function CoreValuesHarness() {
|
||||
const { replaceState } = useCreateFlow();
|
||||
useLayoutEffect(() => {
|
||||
replaceState({
|
||||
selectedCoreValueIds: ["1"],
|
||||
coreValuesChipsSnapshot: [
|
||||
{ id: "1", label: "Accessibility", state: "selected" },
|
||||
],
|
||||
});
|
||||
}, [replaceState]);
|
||||
return <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 }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /^customize$/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /^duplicate$/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens method chip modal read-only until Customize, then enables Save after an edit", async () => {
|
||||
render(<FinalReviewWithCustomizeSelections />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
expect(
|
||||
within(dialog).queryByRole("button", { name: "Save" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const principleField = within(dialog).getByRole("textbox", {
|
||||
name: /core principle/i,
|
||||
});
|
||||
expect(principleField).toBeDisabled();
|
||||
|
||||
await enterMethodCustomizeFromDialog(dialog);
|
||||
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Save" }),
|
||||
).toBeDisabled();
|
||||
|
||||
fireEvent.change(principleField, { target: { value: "Edited principle" } });
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Save" }),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a core-values chip with the matching preset meaning/signals", async () => {
|
||||
function CoreValuesHarness() {
|
||||
const { replaceState } = useCreateFlow();
|
||||
@@ -236,7 +333,7 @@ describe("FinalReviewScreen — chip detail modal", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the editable Save modal for a values chip (parity with method chips)", async () => {
|
||||
it("opens the editable Save modal for a values chip after Customize", async () => {
|
||||
// Customize / plain custom-rule path: snapshot is set, sections is not.
|
||||
function CoreValuesHarness() {
|
||||
const { replaceState } = useCreateFlow();
|
||||
@@ -256,6 +353,10 @@ describe("FinalReviewScreen — chip detail modal", () => {
|
||||
await screen.findByRole("button", { name: "Accessibility" }),
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(
|
||||
within(dialog).queryByRole("button", { name: "Save" }),
|
||||
).not.toBeInTheDocument();
|
||||
await enterCoreValueCustomizeFromDialog(dialog);
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Save" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -264,7 +365,7 @@ describe("FinalReviewScreen — chip detail modal", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the editable Save modal for a values chip in the use-without-changes flow", async () => {
|
||||
it("opens Save for values chip after Customize (use-without-changes seeded snapshot)", async () => {
|
||||
// Mirrors the post-fix payload from `handleUseTemplateWithoutChanges`:
|
||||
// template Values section is stripped from `sections`, snapshot +
|
||||
// selected ids are seeded so the chip carries an `overrideKey`.
|
||||
@@ -294,6 +395,10 @@ describe("FinalReviewScreen — chip detail modal", () => {
|
||||
await screen.findByRole("button", { name: "Accessibility" }),
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(
|
||||
within(dialog).queryByRole("button", { name: "Save" }),
|
||||
).not.toBeInTheDocument();
|
||||
await enterCoreValueCustomizeFromDialog(dialog);
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Save" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -312,6 +417,16 @@ describe("FinalReviewScreen — chip detail modal", () => {
|
||||
* 3. Closing without Save discards every typed change.
|
||||
*/
|
||||
describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
async function enterMethodCustomizeFromDialog(dialog: HTMLElement) {
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /more options/i }),
|
||||
);
|
||||
const customize = await screen.findByRole("menuitem", {
|
||||
name: /^customize$/i,
|
||||
});
|
||||
fireEvent.click(customize);
|
||||
}
|
||||
|
||||
const baseSelections: CreateFlowState = {
|
||||
title: "Oak Park Commons",
|
||||
selectedCommunicationMethodIds: ["signal"],
|
||||
@@ -331,11 +446,14 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await enterMethodCustomizeFromDialog(dialog);
|
||||
const saveButton = within(dialog).getByRole("button", { name: "Save" });
|
||||
expect(saveButton).toBeDisabled();
|
||||
const principleField = within(dialog).getByRole("textbox", {
|
||||
name: /core principle/i,
|
||||
});
|
||||
|
||||
const [firstTextarea] = within(dialog).getAllByRole("textbox");
|
||||
fireEvent.change(firstTextarea, {
|
||||
fireEvent.change(principleField, {
|
||||
target: { value: "Edited principle" },
|
||||
});
|
||||
|
||||
@@ -359,14 +477,21 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const [firstTextarea] = within(dialog).getAllByRole("textbox");
|
||||
fireEvent.change(firstTextarea, {
|
||||
await enterMethodCustomizeFromDialog(dialog);
|
||||
const principleField = within(dialog).getByRole("textbox", {
|
||||
name: /core principle/i,
|
||||
});
|
||||
fireEvent.change(principleField, {
|
||||
target: { value: "Edited principle" },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).queryByRole("button", {
|
||||
name: "Save",
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -388,15 +513,23 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Signal" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const [firstTextarea] = within(dialog).getAllByRole("textbox");
|
||||
fireEvent.change(firstTextarea, {
|
||||
await enterMethodCustomizeFromDialog(dialog);
|
||||
const principleField = within(dialog).getByRole("textbox", {
|
||||
name: /core principle/i,
|
||||
});
|
||||
fireEvent.change(principleField, {
|
||||
target: { value: "Should NOT persist" },
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
try {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
confirmSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(latest.communicationMethodDetailsById).toBeUndefined();
|
||||
});
|
||||
@@ -424,11 +557,41 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(
|
||||
within(dialog).getByText(/title and description you set/i),
|
||||
within(dialog).getByText(/no custom fields yet/i),
|
||||
).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows custom communication chip when template sections exist (customize-from-template)", async () => {
|
||||
const customId = "550e8400-e29b-41d4-a716-446655440999";
|
||||
render(
|
||||
<FinalReviewWithStateProbe
|
||||
onState={() => {}}
|
||||
initial={{
|
||||
title: "Oak Park Commons",
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "Signal", body: "…" }],
|
||||
},
|
||||
],
|
||||
selectedCommunicationMethodIds: ["signal", customId],
|
||||
customMethodCardMetaById: {
|
||||
[customId]: {
|
||||
label: "Garden IRC",
|
||||
supportText: "Support line from wizard",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Garden IRC" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Signal" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows editable field blocks for user-authored communication chips when configured", async () => {
|
||||
const customId = "550e8400-e29b-41d4-a716-446655440000";
|
||||
render(
|
||||
@@ -461,12 +624,13 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
await screen.findByRole("button", { name: "Custom Comm" }),
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await enterMethodCustomizeFromDialog(dialog);
|
||||
expect(
|
||||
within(dialog).queryByText(/title and description you set/i),
|
||||
within(dialog).queryByText(/no custom fields yet/i),
|
||||
).not.toBeInTheDocument();
|
||||
const textarea = within(dialog).getByRole("textbox");
|
||||
expect(textarea).not.toBeDisabled();
|
||||
expect(textarea).toHaveValue("Detail here");
|
||||
const notesField = within(dialog).getByRole("textbox", { name: /notes/i });
|
||||
expect(notesField).not.toBeDisabled();
|
||||
expect(notesField).toHaveValue("Detail here");
|
||||
});
|
||||
|
||||
it("persists field block edits for user-authored communication chips on Save", async () => {
|
||||
@@ -504,19 +668,20 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
|
||||
await screen.findByRole("button", { name: "Custom Comm" }),
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const textarea = within(dialog).getByRole("textbox");
|
||||
fireEvent.change(textarea, { target: { value: "Saved detail" } });
|
||||
await enterMethodCustomizeFromDialog(dialog);
|
||||
const notesField = within(dialog).getByRole("textbox", { name: /notes/i });
|
||||
fireEvent.change(notesField, { target: { value: "Saved detail" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
latest.customMethodCardFieldBlocksById?.[customId]?.[0],
|
||||
).toMatchObject({
|
||||
kind: "text",
|
||||
placeholderText: "Saved detail",
|
||||
expect(
|
||||
latest.customMethodCardFieldBlocksById?.[customId]?.[0],
|
||||
).toMatchObject({
|
||||
kind: "text",
|
||||
placeholderText: "Saved detail",
|
||||
});
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
captureMethodCardCustomizeSnapshot,
|
||||
confirmDiscardMethodCardCustomizeSession,
|
||||
isMethodCardCustomizeSessionDirty,
|
||||
} from "../../lib/create/methodCardCustomizeSession";
|
||||
|
||||
const HEADER_0 = { title: "", description: "" };
|
||||
|
||||
describe("methodCardCustomizeSession", () => {
|
||||
it("reports clean session when pendingDraft and blocks match snapshot", () => {
|
||||
const draft = { a: 1, b: [2] };
|
||||
const snap = captureMethodCardCustomizeSnapshot(draft, null, HEADER_0);
|
||||
expect(
|
||||
isMethodCardCustomizeSessionDirty(snap, { ...draft }, null, HEADER_0),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reports dirty when pendingDraft JSON differs", () => {
|
||||
const snap = captureMethodCardCustomizeSnapshot({ x: "one" }, null, HEADER_0);
|
||||
expect(
|
||||
isMethodCardCustomizeSessionDirty(snap, { x: "two" }, null, HEADER_0),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports dirty when field blocks differ", () => {
|
||||
const before: CustomMethodCardFieldBlock[] = [
|
||||
{ kind: "text", id: "b1", blockTitle: "t", placeholderText: "" },
|
||||
];
|
||||
const snap = captureMethodCardCustomizeSnapshot({ ok: true }, before, HEADER_0);
|
||||
const after: CustomMethodCardFieldBlock[] = [
|
||||
{
|
||||
kind: "text",
|
||||
id: "b1",
|
||||
blockTitle: "t",
|
||||
placeholderText: "edited",
|
||||
},
|
||||
];
|
||||
expect(
|
||||
isMethodCardCustomizeSessionDirty(snap, { ok: true }, after, HEADER_0),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports dirty when header draft differs", () => {
|
||||
const snap = captureMethodCardCustomizeSnapshot({ ok: true }, null, {
|
||||
title: "A",
|
||||
description: "B",
|
||||
});
|
||||
expect(
|
||||
isMethodCardCustomizeSessionDirty(
|
||||
snap,
|
||||
{ ok: true },
|
||||
null,
|
||||
{ title: "A2", description: "B" },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("confirmDiscard skips confirm when unlocked but snapshot missing", () => {
|
||||
const spy = vi.spyOn(window, "confirm");
|
||||
expect(
|
||||
confirmDiscardMethodCardCustomizeSession(
|
||||
true,
|
||||
null,
|
||||
{ x: 1 },
|
||||
null,
|
||||
null,
|
||||
"msg",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("confirmDiscard runs confirm when dirty", () => {
|
||||
const spy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
const draft = { n: 1 };
|
||||
const snap = captureMethodCardCustomizeSnapshot(draft, null, HEADER_0);
|
||||
expect(
|
||||
confirmDiscardMethodCardCustomizeSession(
|
||||
true,
|
||||
snap,
|
||||
{ n: 2 },
|
||||
null,
|
||||
HEADER_0,
|
||||
"Discard?",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(spy).toHaveBeenCalledWith("Discard?");
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ describe("Create flow communication-methods page", () => {
|
||||
expect(within(dialog).getByText("Add Platform")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("re-opening a selected method shows Remove as the modal primary action", async () => {
|
||||
test("re-opening a selected method shows no modal primary; Remove is in the kebab", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CommunicationMethodsScreen />);
|
||||
|
||||
@@ -54,11 +54,17 @@ describe("Create flow communication-methods page", () => {
|
||||
await user.click(signalCards[0]);
|
||||
const dialogAgain = screen.getByRole("dialog");
|
||||
expect(
|
||||
within(dialogAgain).getByRole("button", { name: "Remove" }),
|
||||
).toBeInTheDocument();
|
||||
within(dialogAgain).queryByRole("button", { name: "Remove" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(dialogAgain).queryByRole("button", { name: "Add Platform" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(within(dialogAgain).getByRole("button", { name: "More options" }));
|
||||
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Remove in the modal deselects the method", async () => {
|
||||
test("Remove from the kebab deselects the method", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CommunicationMethodsScreen />);
|
||||
|
||||
@@ -76,12 +82,50 @@ describe("Create flow communication-methods page", () => {
|
||||
|
||||
await user.click(signalCards[0]);
|
||||
await user.click(
|
||||
within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }),
|
||||
within(screen.getByRole("dialog")).getByRole("button", {
|
||||
name: "More options",
|
||||
}),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Remove" }));
|
||||
|
||||
expect(signalCards[0]).not.toHaveTextContent("SELECTED");
|
||||
});
|
||||
|
||||
test("kebab menu does not include Close", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CommunicationMethodsScreen />);
|
||||
|
||||
const signalCards = screen.getAllByRole("button", {
|
||||
name: /Signal: Encrypted messaging/,
|
||||
});
|
||||
await user.click(signalCards[0]);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
await user.click(within(dialog).getByRole("button", { name: "More options" }));
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: "Close" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("unselected preset method fields are disabled until Customize", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CommunicationMethodsScreen />);
|
||||
|
||||
const signalCards = screen.getAllByRole("button", {
|
||||
name: /Signal: Encrypted messaging/,
|
||||
});
|
||||
await user.click(signalCards[0]);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const textbox = within(dialog).getAllByRole("textbox")[0];
|
||||
expect(textbox).toBeDisabled();
|
||||
|
||||
await user.click(within(dialog).getByRole("button", { name: "More options" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Customize" }));
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getAllByRole("textbox")[0],
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("renders without error", () => {
|
||||
render(<CommunicationMethodsScreen />);
|
||||
|
||||
@@ -152,7 +196,7 @@ describe("Create flow communication-methods page", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opening Create modal for custom policy shows saved field blocks", async () => {
|
||||
test("opening Create modal for custom policy shows saved field blocks read-only until Customize", async () => {
|
||||
const user = userEvent.setup();
|
||||
const initial = {
|
||||
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
|
||||
@@ -180,12 +224,23 @@ describe("Create flow communication-methods page", () => {
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getByText("Guidelines")).toBeInTheDocument();
|
||||
const textarea = within(dialog).getByRole("textbox");
|
||||
expect(textarea).not.toBeDisabled();
|
||||
const textboxesBefore = within(dialog).getAllByRole("textbox");
|
||||
expect(textboxesBefore).toHaveLength(1);
|
||||
const textarea = textboxesBefore[0];
|
||||
expect(textarea).toBeDisabled();
|
||||
expect(textarea).toHaveValue("Enter norms here");
|
||||
|
||||
await user.click(within(dialog).getByRole("button", { name: "More options" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Customize" }));
|
||||
|
||||
const guidelinesAfter = within(screen.getByRole("dialog")).getAllByRole(
|
||||
"textbox",
|
||||
)[2];
|
||||
expect(guidelinesAfter).not.toBeDisabled();
|
||||
expect(guidelinesAfter).toHaveValue("Enter norms here");
|
||||
});
|
||||
|
||||
test("opening Create modal for custom policy shows badge options as chips", async () => {
|
||||
test("opening Create modal for custom policy shows badge options as chips read-only until Customize", async () => {
|
||||
const user = userEvent.setup();
|
||||
const initial = {
|
||||
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
|
||||
@@ -213,13 +268,25 @@ describe("Create flow communication-methods page", () => {
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getByText("Choose channels")).toBeInTheDocument();
|
||||
const alpha = within(dialog).getByRole("button", { name: /Deselect Alpha/ });
|
||||
const beta = within(dialog).getByRole("button", { name: /Deselect Beta/ });
|
||||
expect(alpha).not.toBeDisabled();
|
||||
expect(beta).not.toBeDisabled();
|
||||
const alpha = within(dialog).getByRole("button", { name: /^Alpha$/ });
|
||||
const beta = within(dialog).getByRole("button", { name: /^Beta$/ });
|
||||
expect(alpha).toBeDisabled();
|
||||
expect(beta).toBeDisabled();
|
||||
|
||||
await user.click(within(dialog).getByRole("button", { name: "More options" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Customize" }));
|
||||
|
||||
const alphaAfter = within(screen.getByRole("dialog")).getByRole("button", {
|
||||
name: /Deselect Alpha/,
|
||||
});
|
||||
const betaAfter = within(screen.getByRole("dialog")).getByRole("button", {
|
||||
name: /Deselect Beta/,
|
||||
});
|
||||
expect(alphaAfter).not.toBeDisabled();
|
||||
expect(betaAfter).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("editing custom policy field blocks updates draft state", async () => {
|
||||
test("editing custom policy field blocks updates draft state after Save", async () => {
|
||||
const user = userEvent.setup();
|
||||
let latest = {};
|
||||
function Probe({ initial }) {
|
||||
@@ -254,10 +321,16 @@ describe("Create flow communication-methods page", () => {
|
||||
name: /My policy: Support copy/,
|
||||
});
|
||||
await user.click(policyTiles[0]);
|
||||
const textarea = within(screen.getByRole("dialog")).getByRole("textbox");
|
||||
const dialog = screen.getByRole("dialog");
|
||||
await user.click(within(dialog).getByRole("button", { name: "More options" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Customize" }));
|
||||
|
||||
const textarea = within(dialog).getAllByRole("textbox")[2];
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, "Updated norms");
|
||||
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
|
||||
const row = latest.customMethodCardFieldBlocksById?.[CUSTOM_POLICY_ID]?.[0];
|
||||
expect(row).toMatchObject({
|
||||
kind: "text",
|
||||
@@ -265,4 +338,83 @@ describe("Create flow communication-methods page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("duplicate staged copy is unselected; closing modal drops ephemeral card; duplicate again works", async () => {
|
||||
const user = userEvent.setup();
|
||||
let latest = {};
|
||||
function Probe({ initial }) {
|
||||
const { replaceState, state } = useCreateFlow();
|
||||
useLayoutEffect(() => {
|
||||
replaceState(initial);
|
||||
}, [replaceState, initial]);
|
||||
useLayoutEffect(() => {
|
||||
latest = state;
|
||||
}, [state]);
|
||||
return <CommunicationMethodsScreen />;
|
||||
}
|
||||
const initial = {
|
||||
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
|
||||
customMethodCardMetaById: {
|
||||
[CUSTOM_POLICY_ID]: { label: "My policy", supportText: "Support copy" },
|
||||
},
|
||||
customMethodCardFieldBlocksById: {
|
||||
[CUSTOM_POLICY_ID]: [
|
||||
{
|
||||
kind: "text",
|
||||
id: "f1",
|
||||
blockTitle: "Guidelines",
|
||||
placeholderText: "Enter norms here",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
render(<Probe initial={initial} />);
|
||||
|
||||
const policyTiles = screen.getAllByRole("button", {
|
||||
name: /My policy: Support copy/,
|
||||
});
|
||||
await user.click(policyTiles[0]);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
await user.click(
|
||||
within(dialog).getByRole("button", { name: "More options" }),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Duplicate" }));
|
||||
|
||||
const metaAfterDup = latest.customMethodCardMetaById ?? {};
|
||||
const dupIds = Object.keys(metaAfterDup).filter(
|
||||
(id) => id !== CUSTOM_POLICY_ID,
|
||||
);
|
||||
expect(dupIds).toHaveLength(1);
|
||||
const dupId = dupIds[0];
|
||||
expect(latest.selectedCommunicationMethodIds).toEqual([CUSTOM_POLICY_ID]);
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Add Platform" }),
|
||||
).toBeInTheDocument();
|
||||
expect(metaAfterDup[dupId].label.endsWith(" (copy)")).toBe(true);
|
||||
expect(within(dialog).getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
/^My policy \(copy\)$/,
|
||||
);
|
||||
expect(within(dialog).getByText("Support copy")).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("Guidelines")).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole("textbox")).toHaveValue("Enter norms here");
|
||||
await user.click(
|
||||
within(dialog).getByRole("button", { name: "Close dialog" }),
|
||||
);
|
||||
|
||||
const metaAfterClose = latest.customMethodCardMetaById ?? {};
|
||||
expect(metaAfterClose[dupId]).toBeUndefined();
|
||||
expect(Object.keys(metaAfterClose)).toEqual([CUSTOM_POLICY_ID]);
|
||||
|
||||
await user.click(policyTiles[0]);
|
||||
const dialog2 = screen.getByRole("dialog");
|
||||
await user.click(
|
||||
within(dialog2).getByRole("button", { name: "More options" }),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Duplicate" }));
|
||||
const metaSecond = latest.customMethodCardMetaById ?? {};
|
||||
const dupIds2 = Object.keys(metaSecond).filter(
|
||||
(id) => id !== CUSTOM_POLICY_ID,
|
||||
);
|
||||
expect(dupIds2).toHaveLength(1);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -181,7 +181,7 @@ describe("Create flow decision-approaches page", () => {
|
||||
expect(screen.getByText("SELECTED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("re-opening a selected approach shows Remove as the modal primary action", async () => {
|
||||
test("re-opening a selected approach shows no modal primary; Remove is in the kebab", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
@@ -199,11 +199,17 @@ describe("Create flow decision-approaches page", () => {
|
||||
await user.click(card);
|
||||
const dialogAgain = screen.getByRole("dialog");
|
||||
expect(
|
||||
within(dialogAgain).getByRole("button", { name: "Remove" }),
|
||||
).toBeInTheDocument();
|
||||
within(dialogAgain).queryByRole("button", { name: "Remove" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(dialogAgain).queryByRole("button", { name: "Add Approach" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(within(dialogAgain).getByRole("button", { name: "More options" }));
|
||||
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Remove in the modal deselects the approach", async () => {
|
||||
test("Remove from the kebab deselects the approach", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
@@ -221,12 +227,37 @@ describe("Create flow decision-approaches page", () => {
|
||||
|
||||
await user.click(card);
|
||||
await user.click(
|
||||
within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }),
|
||||
within(screen.getByRole("dialog")).getByRole("button", { name: "More options" }),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Remove" }));
|
||||
|
||||
expect(card).not.toHaveTextContent("SELECTED");
|
||||
});
|
||||
|
||||
test("when editing a published rule, method modal kebab has no Duplicate", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DecisionApproachesScreenWithState
|
||||
initial={{
|
||||
editingPublishedRuleId: "published-rule-1",
|
||||
selectedDecisionApproachIds: ["lazy-consensus"],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const card = screen.getByRole("button", {
|
||||
name: /Lazy Consensus: A decision is assumed approved/,
|
||||
});
|
||||
await user.click(card);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.click(
|
||||
within(dialog).getByRole("button", { name: "More options" }),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: "Duplicate" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("message box checkboxes are interactive", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DecisionApproachesScreen />);
|
||||
|
||||
@@ -166,4 +166,52 @@ describe("applyFinalReviewChipEditPatch", () => {
|
||||
"550e8400-e29b-41d4-a716-446655440000": patch.customMethodCardFieldBlocks,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges customMethodCardMetaById when the patch carries methodCardMeta", () => {
|
||||
const state: CreateFlowState = {
|
||||
customMethodCardMetaById: {
|
||||
signal: { label: "Signal", supportText: "Old" },
|
||||
},
|
||||
};
|
||||
const patch: FinalReviewChipEditPatch = {
|
||||
groupKey: "communication",
|
||||
overrideKey: "signal",
|
||||
value: {
|
||||
corePrinciple: "p",
|
||||
logisticsAdmin: "l",
|
||||
codeOfConduct: "c",
|
||||
},
|
||||
methodCardMeta: { label: "Signal (edited)", supportText: "New sub" },
|
||||
};
|
||||
|
||||
const result = applyFinalReviewChipEditPatch(state, patch);
|
||||
|
||||
expect(result.customMethodCardMetaById).toEqual({
|
||||
signal: { label: "Signal (edited)", supportText: "New sub" },
|
||||
});
|
||||
});
|
||||
|
||||
it("updates coreValuesChipsSnapshot label when patch carries chipLabel", () => {
|
||||
const state: CreateFlowState = {
|
||||
coreValuesChipsSnapshot: [
|
||||
{ id: "1", label: "Accessibility", state: "selected" },
|
||||
],
|
||||
coreValueDetailsByChipId: { "1": { meaning: "m", signals: "s" } },
|
||||
};
|
||||
const patch: FinalReviewChipEditPatch = {
|
||||
groupKey: "coreValues",
|
||||
overrideKey: "1",
|
||||
value: { meaning: "m2", signals: "s2" },
|
||||
chipLabel: "A11y renamed",
|
||||
};
|
||||
|
||||
const result = applyFinalReviewChipEditPatch(state, patch);
|
||||
|
||||
expect(result.coreValuesChipsSnapshot).toEqual([
|
||||
{ id: "1", label: "A11y renamed", state: "selected" },
|
||||
]);
|
||||
expect(result.coreValueDetailsByChipId).toEqual({
|
||||
"1": { meaning: "m2", signals: "s2" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
|
||||
expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]);
|
||||
});
|
||||
|
||||
it("prefers state.sections when populated (use-without-changes path)", () => {
|
||||
it("uses section titles for method facets when selections were cleared (use-without-changes)", () => {
|
||||
const state: CreateFlowState = {
|
||||
sections: [
|
||||
{
|
||||
@@ -95,10 +95,7 @@ describe("buildFinalReviewCategoriesFromState", () => {
|
||||
entries: [{ title: "Signal", body: "…" }],
|
||||
},
|
||||
],
|
||||
// Selection ids must be ignored when sections is present — the
|
||||
// "Use without changes" handler resets them for exactly that reason,
|
||||
// but we double-check the helper honors the sections branch first.
|
||||
selectedCommunicationMethodIds: ["in-person-meetings"],
|
||||
selectedCommunicationMethodIds: [],
|
||||
};
|
||||
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
|
||||
expect(rows).toEqual([
|
||||
@@ -107,6 +104,41 @@ describe("buildFinalReviewCategoriesFromState", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("when sections exist but facet selections are set, matches publish pickMethodIds (state wins)", () => {
|
||||
const state: CreateFlowState = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "Signal", body: "…" }],
|
||||
},
|
||||
],
|
||||
selectedCommunicationMethodIds: ["in-person-meetings"],
|
||||
};
|
||||
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
|
||||
expect(rows).toEqual([
|
||||
{ name: "Communication", chips: ["In-Person Meetings"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows custom communication chips when sections exist and selections include a UUID", () => {
|
||||
const customId = "00000000-0000-4000-8000-000000000099";
|
||||
const state: CreateFlowState = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "Signal", body: "…" }],
|
||||
},
|
||||
],
|
||||
selectedCommunicationMethodIds: ["signal", customId],
|
||||
customMethodCardMetaById: {
|
||||
[customId]: { label: "Garden IRC", supportText: "x" },
|
||||
},
|
||||
};
|
||||
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
|
||||
const comm = rows.find((r) => r.name === "Communication");
|
||||
expect(comm?.chips).toEqual(["Signal", "Garden IRC"]);
|
||||
});
|
||||
|
||||
it("prepends a Values row from coreValuesChipsSnapshot when sections lack one", () => {
|
||||
const state: CreateFlowState = {
|
||||
sections: [
|
||||
|
||||
@@ -188,6 +188,43 @@ describe("buildPublishPayload — methodSelections", () => {
|
||||
expect(ms?.communication?.[0]?.label).toBe("Custom Comm");
|
||||
});
|
||||
|
||||
it("embeds wizard field blocks in published Communication sections for custom UUID ids", () => {
|
||||
const customId = "00000000-0000-4000-8000-000000000099";
|
||||
const r = buildPublishPayload({
|
||||
title: "T",
|
||||
selectedCommunicationMethodIds: [customId],
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "Template row", body: "placeholder" }],
|
||||
},
|
||||
],
|
||||
customMethodCardMetaById: {
|
||||
[customId]: { label: "Wizard title", supportText: "" },
|
||||
},
|
||||
customMethodCardFieldBlocksById: {
|
||||
[customId]: [
|
||||
{
|
||||
kind: "text",
|
||||
id: "b1",
|
||||
blockTitle: "Field A",
|
||||
placeholderText: "User-authored body",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const secs = r.document.sections as Array<{
|
||||
categoryName: string;
|
||||
entries: Array<{ blocks?: Array<{ label: string; body: string }> }>;
|
||||
}>;
|
||||
const comm = secs.find((s) => s.categoryName === "Communication");
|
||||
expect(comm?.entries[0]?.blocks).toEqual([
|
||||
{ label: "Field A", body: "User-authored body" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits preset-only sections when a method is selected without an override", () => {
|
||||
const r = buildPublishPayload({
|
||||
title: "T",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { CreateFlowState } from "../../../app/(app)/create/types";
|
||||
|
||||
const deleteServerDraft = vi.fn();
|
||||
const saveDraftToServer = vi.fn();
|
||||
const updatePublishedRule = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/create/buildPublishPayload", () => ({
|
||||
buildPublishPayload: vi.fn(() => ({
|
||||
ok: true as const,
|
||||
title: "T",
|
||||
summary: "S",
|
||||
document: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/create/api", () => ({
|
||||
deleteServerDraft: (...args: unknown[]) => deleteServerDraft(...args),
|
||||
saveDraftToServer: (...args: unknown[]) => saveDraftToServer(...args),
|
||||
updatePublishedRule: (...args: unknown[]) => updatePublishedRule(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/create/lastPublishedRule", () => ({
|
||||
writeLastPublishedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
async function loadExitHook() {
|
||||
return import("../../../app/(app)/create/hooks/useCreateFlowExit");
|
||||
}
|
||||
|
||||
describe("useCreateFlowExit", () => {
|
||||
const router = { push: vi.fn() };
|
||||
const clearState = vi.fn();
|
||||
const user = { id: "u1", email: "a@b.c" };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv("NEXT_PUBLIC_ENABLE_BACKEND_SYNC", "true");
|
||||
deleteServerDraft.mockReset();
|
||||
saveDraftToServer.mockReset();
|
||||
updatePublishedRule.mockReset();
|
||||
router.push.mockReset();
|
||||
clearState.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not delete the server draft after updating a published rule (preserves other in-progress work)", async () => {
|
||||
updatePublishedRule.mockResolvedValue({ ok: true as const });
|
||||
|
||||
const { useCreateFlowExit } = await loadExitHook();
|
||||
|
||||
const state: CreateFlowState = {
|
||||
editingPublishedRuleId: "rule-1",
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCreateFlowExit({
|
||||
state,
|
||||
currentStep: "edit-rule",
|
||||
clearState,
|
||||
router,
|
||||
user,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current({ saveDraft: true });
|
||||
});
|
||||
|
||||
expect(updatePublishedRule).toHaveBeenCalledWith(
|
||||
"rule-1",
|
||||
expect.objectContaining({ title: "T" }),
|
||||
);
|
||||
expect(deleteServerDraft).not.toHaveBeenCalled();
|
||||
expect(router.push).toHaveBeenCalledWith("/");
|
||||
});
|
||||
});
|
||||
@@ -22,4 +22,23 @@ describe("mergePresetMethodsWithCustom", () => {
|
||||
supportText: "cx",
|
||||
});
|
||||
});
|
||||
|
||||
it("overlays meta label/supportText onto preset ids for card display", () => {
|
||||
const presets = [
|
||||
{ id: "signal", label: "Signal", supportText: "preset sub" },
|
||||
];
|
||||
const merged = mergePresetMethodsWithCustom(
|
||||
presets,
|
||||
["signal"],
|
||||
{
|
||||
signal: { label: "Renamed", supportText: "user sub" },
|
||||
},
|
||||
);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]).toEqual({
|
||||
id: "signal",
|
||||
label: "Renamed",
|
||||
supportText: "user sub",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { communicationPresetFor } from "../../lib/create/finalReviewChipPresets";
|
||||
import { communicationMethodFacetMatchesPreset } from "../../lib/create/methodCardFacetMatchesPresetForId";
|
||||
|
||||
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
describe("methodCardFacetMatchesPresetForId", () => {
|
||||
it("communication: matches fresh preset seed for an unknown id", () => {
|
||||
const p = communicationPresetFor(uuid);
|
||||
expect(communicationMethodFacetMatchesPreset(p, uuid)).toBe(true);
|
||||
});
|
||||
|
||||
it("communication: mismatches when any section differs from preset", () => {
|
||||
const p = communicationPresetFor(uuid);
|
||||
expect(
|
||||
communicationMethodFacetMatchesPreset(
|
||||
{ ...p, corePrinciple: "edited" },
|
||||
uuid,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createFlowStateFromPublishedRule,
|
||||
isPublishedRuleHydratePatchIncomplete,
|
||||
isPublishedRuleSelectionMissing,
|
||||
methodSectionsPinsForHydratedSelections,
|
||||
methodSectionsPinsFromPublishedHydratePatch,
|
||||
@@ -68,6 +69,72 @@ describe("isPublishedRuleSelectionMissing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPublishedRuleHydratePatchIncomplete", () => {
|
||||
it("is true when facet ids are present but custom method meta from patch is missing in state", () => {
|
||||
const customId = "b7c0a9f3-0000-4000-8000-000000000001";
|
||||
const patch = createFlowStateFromPublishedRule({
|
||||
id: "r",
|
||||
title: "T",
|
||||
summary: "",
|
||||
document: {
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: customId,
|
||||
label: "My custom comms method",
|
||||
sections: {
|
||||
corePrinciple: "x",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = {
|
||||
sections: [],
|
||||
title: "T",
|
||||
editingPublishedRuleId: "r",
|
||||
selectedCommunicationMethodIds: [customId],
|
||||
} as CreateFlowState;
|
||||
expect(isPublishedRuleSelectionMissing(state, patch)).toBe(false);
|
||||
expect(isPublishedRuleHydratePatchIncomplete(state, patch)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when patch meta keys exist on state", () => {
|
||||
const customId = "b7c0a9f3-0000-4000-8000-000000000001";
|
||||
const patch = createFlowStateFromPublishedRule({
|
||||
id: "r",
|
||||
title: "T",
|
||||
summary: "",
|
||||
document: {
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: customId,
|
||||
label: "My custom comms method",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = {
|
||||
sections: [],
|
||||
title: "T",
|
||||
editingPublishedRuleId: "r",
|
||||
selectedCommunicationMethodIds: [customId],
|
||||
customMethodCardMetaById: patch.customMethodCardMetaById,
|
||||
} as CreateFlowState;
|
||||
expect(isPublishedRuleHydratePatchIncomplete(state, patch)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("methodSectionsPinsForHydratedSelections / methodSectionsPinsFromPublishedHydratePatch", () => {
|
||||
it("alias matches hydrated-selection helper output", () => {
|
||||
const partial: Partial<CreateFlowState> = {
|
||||
@@ -218,6 +285,35 @@ describe("createFlowStateFromPublishedRule", () => {
|
||||
expect(partial.sections).toEqual([]);
|
||||
});
|
||||
|
||||
it("hydrates customMethodCardMetaById for user-authored method ids from methodSelections", () => {
|
||||
const customId = "b7c0a9f3-0000-4000-8000-000000000001";
|
||||
const partial = createFlowStateFromPublishedRule({
|
||||
id: "rule-custom",
|
||||
title: "C",
|
||||
summary: "",
|
||||
document: {
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: customId,
|
||||
label: "Custom channel policy",
|
||||
sections: {
|
||||
corePrinciple: "cp",
|
||||
logisticsAdmin: "la",
|
||||
codeOfConduct: "cc",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(partial.selectedCommunicationMethodIds).toEqual([customId]);
|
||||
expect(partial.customMethodCardMetaById?.[customId]).toEqual({
|
||||
label: "Custom channel policy",
|
||||
supportText: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets sections to [] even when methodSelections is missing (edit hydrate)", () => {
|
||||
const partial = createFlowStateFromPublishedRule({
|
||||
id: "rule-2",
|
||||
|
||||
@@ -116,4 +116,96 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => {
|
||||
doc.sections,
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces stale document.sections method category with full methodSelections (custom rules)", () => {
|
||||
const customId = "b7c0a9f3-0000-4000-8000-000000000001";
|
||||
const doc = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [
|
||||
{
|
||||
title: "Slack",
|
||||
body: "Only template row; custom card missing from sections.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
sections: {
|
||||
corePrinciple: "Slack principle",
|
||||
logisticsAdmin: "Slack logistics",
|
||||
codeOfConduct: "Slack conduct",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: customId,
|
||||
label: "My custom comms",
|
||||
sections: {
|
||||
corePrinciple: "Custom principle",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const out = parsePublishedDocumentForCommunityRuleDisplay(doc);
|
||||
const comm = out.find((s) => s.categoryName === "Communication");
|
||||
expect(comm).toBeDefined();
|
||||
expect(comm?.entries.map((e) => e.title)).toEqual([
|
||||
"Slack",
|
||||
"My custom comms",
|
||||
]);
|
||||
expect(
|
||||
comm?.entries.some(
|
||||
(e) => e.title === "My custom comms" && e.blocks?.length,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("includes wizard field blocks when methodSelections preset sections are empty (custom UUID)", () => {
|
||||
const customId = "b7c0a9f3-0000-4000-8000-000000000001";
|
||||
const doc = {
|
||||
sections: [
|
||||
{
|
||||
categoryName: "Communication",
|
||||
entries: [{ title: "Stale template row", body: "ignored after merge" }],
|
||||
},
|
||||
],
|
||||
methodSelections: {
|
||||
communication: [
|
||||
{
|
||||
id: customId,
|
||||
label: "Custom method title",
|
||||
sections: {
|
||||
corePrinciple: "",
|
||||
logisticsAdmin: "",
|
||||
codeOfConduct: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
customMethodCardFieldBlocksById: {
|
||||
[customId]: [
|
||||
{
|
||||
kind: "text" as const,
|
||||
id: "f1",
|
||||
blockTitle: "Expectations",
|
||||
placeholderText: "Answer stored only on field blocks.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const out = parsePublishedDocumentForCommunityRuleDisplay(doc);
|
||||
const comm = out.find((s) => s.categoryName === "Communication");
|
||||
expect(comm?.entries.map((e) => e.title)).toEqual(["Custom method title"]);
|
||||
expect(comm?.entries[0]?.blocks).toEqual([
|
||||
{ label: "Expectations", body: "Answer stored only on field blocks." },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { runCompletedStepExit } from "../../app/(app)/create/utils/runCompletedStepExit";
|
||||
|
||||
describe("runCompletedStepExit", () => {
|
||||
it("clears client draft mirrors and navigates home without implying server DELETE", () => {
|
||||
const clearState = vi.fn();
|
||||
const clearAnonymousCreateFlowStorage = vi.fn();
|
||||
const router = { push: vi.fn() };
|
||||
|
||||
runCompletedStepExit({
|
||||
clearState,
|
||||
clearAnonymousCreateFlowStorage,
|
||||
router,
|
||||
});
|
||||
|
||||
expect(clearState).toHaveBeenCalledTimes(1);
|
||||
expect(clearAnonymousCreateFlowStorage).toHaveBeenCalledTimes(1);
|
||||
expect(router.push).toHaveBeenCalledWith("/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { usesWizardFieldBlocksModalBody } from "../../lib/create/usesWizardFieldBlocksModalBody";
|
||||
import type { CustomMethodCardFieldBlock } from "../../lib/create/customMethodCardFieldBlocks";
|
||||
|
||||
const id = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const meta = { [id]: { label: "L", supportText: "S" } };
|
||||
const blocks: CustomMethodCardFieldBlock[] = [
|
||||
{ kind: "text", id: "b1", blockTitle: "T", placeholderText: "p" },
|
||||
];
|
||||
|
||||
describe("usesWizardFieldBlocksModalBody", () => {
|
||||
it("is false without meta row", () => {
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: "signal",
|
||||
meta: {},
|
||||
fieldBlocksById: {},
|
||||
modalEditUnlocked: false,
|
||||
draftFieldBlocks: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when persisted field blocks exist (wizard card)", () => {
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: id,
|
||||
meta,
|
||||
fieldBlocksById: { [id]: blocks },
|
||||
modalEditUnlocked: false,
|
||||
draftFieldBlocks: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is true for proportion-only persisted blocks (read-only modal)", () => {
|
||||
const proportionBlocks: CustomMethodCardFieldBlock[] = [
|
||||
{
|
||||
kind: "proportion",
|
||||
id: "p1",
|
||||
blockTitle: "Share of async",
|
||||
defaultPercent: 40,
|
||||
},
|
||||
];
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: id,
|
||||
meta,
|
||||
fieldBlocksById: { [id]: proportionBlocks },
|
||||
modalEditUnlocked: false,
|
||||
draftFieldBlocks: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is true when persisted blocks exist even if customMethodCardMetaById row is missing", () => {
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: id,
|
||||
meta: {},
|
||||
fieldBlocksById: { [id]: blocks },
|
||||
modalEditUnlocked: false,
|
||||
draftFieldBlocks: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when meta exists but persisted blocks empty and not editing blocks (preset duplicate stub)", () => {
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: id,
|
||||
meta,
|
||||
fieldBlocksById: { [id]: [] },
|
||||
modalEditUnlocked: false,
|
||||
draftFieldBlocks: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true in read-only modal when custom, blocks empty, and facet matches preset stubs", () => {
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: id,
|
||||
meta,
|
||||
fieldBlocksById: { [id]: [] },
|
||||
modalEditUnlocked: false,
|
||||
draftFieldBlocks: null,
|
||||
customFacetDetailsMatchPreset: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when customizing with empty wizard draft — structured fields stay active", () => {
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: id,
|
||||
meta,
|
||||
fieldBlocksById: {},
|
||||
modalEditUnlocked: true,
|
||||
draftFieldBlocks: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when customizing with non-empty block draft", () => {
|
||||
expect(
|
||||
usesWizardFieldBlocksModalBody({
|
||||
methodId: id,
|
||||
meta,
|
||||
fieldBlocksById: {},
|
||||
modalEditUnlocked: true,
|
||||
draftFieldBlocks: blocks,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user