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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user