Add custom intervention modals

This commit is contained in:
adilallo
2026-05-01 22:05:05 -06:00
parent 58d0e33500
commit dee2dd800e
67 changed files with 3480 additions and 197 deletions
@@ -0,0 +1,279 @@
"use client";
/**
* Controlled field blocks for wizard-authored method cards in Create modals
* (facet screens + final-review chip edit). When `onBlocksChange` is omitted,
* blocks render read-only (disabled controls).
*
* Layout matches preset method editors ({@link CommunicationMethodEditFields},
* {@link DecisionApproachEditFields}): {@link ModalTextAreaField},
* {@link ApplicableScopeField} chip rows, {@link IncrementerBlock}.
*/
import { memo, useCallback, useRef } from "react";
import { useMessages } 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 ApplicableScopeField from "./ApplicableScopeField";
import InputLabel from "../../../components/type/InputLabel";
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
import ModalTextAreaField from "./ModalTextAreaField";
const TEXT_VALUE_MAX = 8000;
export interface CustomMethodCardFieldBlocksSummaryProps {
blocks: CustomMethodCardFieldBlock[];
/** When set, fields update the draft via immutable block-array replacements. */
onBlocksChange?: (_next: CustomMethodCardFieldBlock[]) => void;
}
function mapBlockById(
blocks: CustomMethodCardFieldBlock[],
blockId: string,
mapFn: (_b: CustomMethodCardFieldBlock) => CustomMethodCardFieldBlock,
): CustomMethodCardFieldBlock[] {
return blocks.map((b) => (b.id === blockId ? mapFn(b) : b));
}
function CustomMethodCardUploadBlockRow({
block,
blocks,
patch,
uploadFileInputAriaLabel,
uploadHint,
clearFileLabel,
noFileChosen,
}: {
block: Extract<CustomMethodCardFieldBlock, { kind: "upload" }>;
blocks: CustomMethodCardFieldBlock[];
patch: (_next: CustomMethodCardFieldBlock[]) => void;
uploadFileInputAriaLabel: string;
uploadHint: string;
clearFileLabel: string;
noFileChosen: string;
}) {
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const displayName = block.fileName?.trim() ? block.fileName : noFileChosen;
return (
<div className="flex flex-col gap-2">
<InputLabel
label={block.blockTitle}
helpIcon
size="s"
palette="default"
/>
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
{displayName}
</p>
<input
ref={uploadInputRef}
type="file"
className="sr-only"
tabIndex={-1}
aria-label={uploadFileInputAriaLabel}
onChange={(e) => {
const file = e.target.files?.[0];
const name = file?.name?.trim();
patch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "upload"
? {
...b,
...(name ? { fileName: name } : {}),
}
: b,
),
);
e.target.value = "";
}}
/>
<Upload
hintText={uploadHint}
onClick={() => uploadInputRef.current?.click()}
/>
{block.fileName?.trim() ? (
<InlineTextButton
onClick={() =>
patch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "upload" ? { ...b, fileName: undefined } : b,
),
)
}
>
{clearFileLabel}
</InlineTextButton>
) : null}
</div>
);
}
function CustomMethodCardFieldBlocksSummaryComponent({
blocks,
onBlocksChange,
}: CustomMethodCardFieldBlocksSummaryProps) {
const m = useMessages();
const wiz = m.create.customRule.customMethodCardWizard;
const fm = wiz.fieldModals;
const em = wiz.editModal;
const emptyValue = em.readout.emptyValue;
const noFileChosen = em.readout.noFileChosen;
const readOnly = !onBlocksChange;
const patch = useCallback(
(next: CustomMethodCardFieldBlock[]) => {
onBlocksChange?.(next);
},
[onBlocksChange],
);
return (
<div className="flex flex-col gap-6">
{blocks.map((block) => {
if (block.kind === "text") {
return (
<ModalTextAreaField
key={block.id}
label={block.blockTitle}
rows={6}
value={block.placeholderText}
onChange={(v) =>
patch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "text"
? { ...b, placeholderText: v.slice(0, TEXT_VALUE_MAX) }
: b,
),
)
}
disabled={readOnly}
/>
);
}
if (block.kind === "badges") {
if (readOnly) {
return (
<div key={block.id} className="flex flex-col gap-2">
<InputLabel
label={block.blockTitle}
helpIcon
size="s"
palette="default"
/>
{block.options.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
{block.options.map((opt, idx) => (
<Chip
key={`${block.id}-${idx}`}
label={opt}
state="selected"
palette="default"
size="s"
disabled
ariaLabel={opt}
/>
))}
</div>
) : (
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
{emptyValue}
</p>
)}
</div>
);
}
return (
<ApplicableScopeField
key={block.id}
label={block.blockTitle}
addLabel={fm.badges.addOptionLabel}
scopes={block.options}
selectedScopes={block.options}
onToggleScope={(scope) =>
patch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "badges"
? { ...b, options: b.options.filter((o) => o !== scope) }
: b,
),
)
}
onAddScope={(scope) =>
patch(
mapBlockById(blocks, block.id, (b) => {
if (b.kind !== "badges") return b;
if (b.options.includes(scope) || b.options.length >= 50)
return b;
return { ...b, options: [...b.options, scope] };
}),
)
}
/>
);
}
if (block.kind === "upload") {
return (
<div key={block.id}>
{readOnly ? (
<ModalTextAreaField
label={block.blockTitle}
rows={2}
value={
block.fileName?.trim() ? block.fileName : noFileChosen
}
onChange={() => {}}
disabled
/>
) : (
<CustomMethodCardUploadBlockRow
block={block}
blocks={blocks}
patch={patch}
uploadFileInputAriaLabel={fm.upload.uploadFileInputAriaLabel}
uploadHint={fm.upload.uploadHint}
clearFileLabel={em.clearFileLabel}
noFileChosen={noFileChosen}
/>
)}
</div>
);
}
return (
<IncrementerBlock
key={block.id}
label={block.blockTitle}
value={block.defaultPercent}
min={1}
max={100}
step={1}
disabled={readOnly}
onChange={(v) =>
patch(
mapBlockById(blocks, block.id, (b) =>
b.kind === "proportion" ? { ...b, defaultPercent: v } : b,
),
)
}
formatValue={(v) => `${v}%`}
decrementAriaLabel={fm.proportion.decrementAriaLabel}
incrementAriaLabel={fm.proportion.incrementAriaLabel}
/>
);
})}
</div>
);
}
const CustomMethodCardFieldBlocksSummary = memo(
CustomMethodCardFieldBlocksSummaryComponent,
);
CustomMethodCardFieldBlocksSummary.displayName =
"CustomMethodCardFieldBlocksSummary";
export default CustomMethodCardFieldBlocksSummary;
@@ -0,0 +1,31 @@
"use client";
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
import type { CreateFlowState } from "../types";
import CustomMethodCardFieldBlocksSummary from "./CustomMethodCardFieldBlocksSummary";
import CustomMethodCardPresetEditPlaceholder from "./CustomMethodCardPresetEditPlaceholder";
/** Body for Create modals when the card is user-authored (custom UUID). */
export default function CustomMethodCardModalBody({
cardId,
blocksById,
/** When set, used instead of `blocksById[cardId]` (e.g. final-review draft). */
blocksOverride,
onFieldBlocksChange,
}: {
cardId: string;
blocksById: CreateFlowState["customMethodCardFieldBlocksById"];
blocksOverride?: CustomMethodCardFieldBlock[] | null;
onFieldBlocksChange?: (_blocks: CustomMethodCardFieldBlock[]) => void;
}) {
const blocks = blocksOverride ?? blocksById?.[cardId];
if (blocks && blocks.length > 0) {
return (
<CustomMethodCardFieldBlocksSummary
blocks={blocks}
onBlocksChange={onFieldBlocksChange}
/>
);
}
return <CustomMethodCardPresetEditPlaceholder />;
}
@@ -0,0 +1,26 @@
"use client";
/**
* Shown in method-card Create modals and final-review chip edit when the chip
* is user-authored (`customMethodCardMetaById`) — preset section editors do
* not apply until structured parity exists with wizard field blocks.
*/
import { memo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
function CustomMethodCardPresetEditPlaceholderComponent() {
const m = useMessages();
const body = m.create.customRule.customMethodCardWizard.editModal.placeholderBody;
return (
<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)]">
{body}
</p>
);
}
CustomMethodCardPresetEditPlaceholderComponent.displayName =
"CustomMethodCardPresetEditPlaceholder";
export default memo(CustomMethodCardPresetEditPlaceholderComponent);
@@ -0,0 +1,358 @@
"use client";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
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 { CustomMethodCardWizardView } from "./CustomMethodCardWizard.view";
import type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types";
/**
* Shared 3-step add-custom-method-card flow (Figma Modal / Create — nodes
* `20066:14748`, `20094:48551`, `20066:14361`).
*/
const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
({ isOpen, onClose, onFinalize }) => {
const m = useMessages();
const t = useTranslation("common");
const w = m.create.customRule.customMethodCardWizard;
const copy = useMemo(
() => ({
step1: w.steps["1"],
step2: w.steps["2"],
step3: w.steps["3"],
footerFinalize: w.footer.finalize,
fieldModals: w.fieldModals,
}),
[w.fieldModals, w.footer.finalize, w.steps],
);
const fieldBodiesCopy = useMemo(
() => ({
requiredHint: copy.fieldModals.requiredHint,
text: copy.fieldModals.text,
badges: copy.fieldModals.badges,
upload: copy.fieldModals.upload,
proportion: copy.fieldModals.proportion,
}),
[copy.fieldModals],
);
const [wizardStep, setWizardStep] = useState<1 | 2 | 3>(1);
const [policyTitle, setPolicyTitle] = useState("");
const [policyDescription, setPolicyDescription] = useState("");
const [addFieldExpanded, setAddFieldExpanded] = useState(false);
const [fieldTypeModal, setFieldTypeModal] =
useState<AddCustomFieldType | null>(null);
const [draftFieldBlocks, setDraftFieldBlocks] = useState<
CustomMethodCardFieldBlock[]
>([]);
const [textBlockTitle, setTextBlockTitle] = useState("");
const [textPlaceholderBody, setTextPlaceholderBody] = useState("");
const [badgeBlockTitle, setBadgeBlockTitle] = useState("");
const [badgeOptions, setBadgeOptions] = useState<string[]>([]);
const [uploadBlockTitle, setUploadBlockTitle] = useState("");
const [uploadFileName, setUploadFileName] = useState<string | undefined>(
undefined,
);
const [proportionBlockTitle, setProportionBlockTitle] = useState("");
const [proportionDefault, setProportionDefault] = useState(50);
const fileInputRef = useRef<HTMLInputElement>(null);
const resetFieldTypeDrafts = useCallback(() => {
setTextBlockTitle("");
setTextPlaceholderBody("");
setBadgeBlockTitle("");
setBadgeOptions([]);
setUploadBlockTitle("");
setUploadFileName(undefined);
setProportionBlockTitle("");
setProportionDefault(50);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, []);
const reset = useCallback(() => {
setWizardStep(1);
setPolicyTitle("");
setPolicyDescription("");
setAddFieldExpanded(false);
setFieldTypeModal(null);
setDraftFieldBlocks([]);
resetFieldTypeDrafts();
}, [resetFieldTypeDrafts]);
useEffect(() => {
if (!isOpen) {
reset();
}
}, [isOpen, reset]);
const dismiss = useCallback(() => {
reset();
onClose();
}, [onClose, reset]);
const titleTrim = policyTitle.trim();
const descriptionTrim = policyDescription.trim();
const stepValid = useMemo(() => {
const titleOk =
titleTrim.length > 0 &&
titleTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
const descriptionOk =
descriptionTrim.length > 0 &&
descriptionTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
if (wizardStep === 1) return titleOk;
if (wizardStep === 2) return descriptionOk;
return titleOk && descriptionOk;
}, [
descriptionTrim.length,
titleTrim.length,
wizardStep,
]);
const fieldModalStepValid = useMemo(() => {
if (!fieldTypeModal) return false;
if (fieldTypeModal === "text") {
const t0 = textBlockTitle.trim();
return (
t0.length > 0 &&
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS
);
}
if (fieldTypeModal === "badges") {
const t0 = badgeBlockTitle.trim();
return (
t0.length > 0 &&
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS
);
}
if (fieldTypeModal === "upload") {
const t0 = uploadBlockTitle.trim();
return (
t0.length > 0 &&
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS
);
}
const t0 = proportionBlockTitle.trim();
return (
t0.length > 0 &&
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS &&
proportionDefault >= 1 &&
proportionDefault <= 100
);
}, [
badgeBlockTitle,
fieldTypeModal,
proportionBlockTitle,
proportionDefault,
textBlockTitle,
uploadBlockTitle,
]);
const headerTitle =
wizardStep === 1
? copy.step1.title
: wizardStep === 2
? copy.step2.title
: copy.step3.title;
const headerDescription =
wizardStep === 1
? copy.step1.description
: wizardStep === 2
? copy.step2.description
: copy.step3.description;
const fieldModalHeader = fieldTypeModal
? copy.fieldModals[fieldTypeModal]
: null;
const shellTitle = fieldModalHeader?.title ?? headerTitle;
const shellDescription = fieldModalHeader?.description ?? headerDescription;
const nextLabel = fieldTypeModal
? copy.fieldModals.addField
: wizardStep === 3
? copy.footerFinalize
: t("buttons.next");
const shellNextDisabled = fieldTypeModal
? !fieldModalStepValid
: !stepValid;
const handleShellClose = useCallback(() => {
if (fieldTypeModal) {
setFieldTypeModal(null);
return;
}
dismiss();
}, [dismiss, fieldTypeModal]);
const handleBack = useCallback(() => {
if (fieldTypeModal) {
setFieldTypeModal(null);
return;
}
if (wizardStep === 1) {
dismiss();
return;
}
setWizardStep((s) => (s === 2 ? 1 : 2));
}, [dismiss, fieldTypeModal, wizardStep]);
const handleSelectFieldType = useCallback((ft: AddCustomFieldType) => {
resetFieldTypeDrafts();
setFieldTypeModal(ft);
}, [resetFieldTypeDrafts]);
const handleFileChosen = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
setUploadFileName(file?.name);
},
[],
);
const handleBadgeAddOption = useCallback((label: string) => {
setBadgeOptions((prev) =>
prev.includes(label) ? prev : [...prev, label],
);
}, []);
const appendFieldBlock = useCallback(() => {
if (!fieldTypeModal || !fieldModalStepValid) return;
const id = crypto.randomUUID();
let block: CustomMethodCardFieldBlock;
switch (fieldTypeModal) {
case "text":
block = {
kind: "text",
id,
blockTitle: textBlockTitle.trim(),
placeholderText: textPlaceholderBody,
};
break;
case "badges":
block = {
kind: "badges",
id,
blockTitle: badgeBlockTitle.trim(),
options: [...badgeOptions],
};
break;
case "upload":
block = {
kind: "upload",
id,
blockTitle: uploadBlockTitle.trim(),
fileName: uploadFileName,
};
break;
default:
block = {
kind: "proportion",
id,
blockTitle: proportionBlockTitle.trim(),
defaultPercent: proportionDefault,
};
}
setDraftFieldBlocks((prev) => [...prev, block]);
setFieldTypeModal(null);
}, [
badgeBlockTitle,
badgeOptions,
fieldModalStepValid,
fieldTypeModal,
proportionBlockTitle,
proportionDefault,
textBlockTitle,
textPlaceholderBody,
uploadBlockTitle,
uploadFileName,
]);
const handleNext = useCallback(() => {
if (fieldTypeModal) {
appendFieldBlock();
return;
}
if (!stepValid) return;
if (wizardStep === 3) {
onFinalize({
title: titleTrim,
description: descriptionTrim,
fieldBlocks: draftFieldBlocks,
});
dismiss();
return;
}
setWizardStep((s) => (s === 1 ? 2 : 3));
}, [
appendFieldBlock,
descriptionTrim,
dismiss,
draftFieldBlocks,
fieldTypeModal,
onFinalize,
stepValid,
titleTrim,
wizardStep,
]);
return (
<CustomMethodCardWizardView
isOpen={isOpen}
onDismiss={handleShellClose}
wizardStep={wizardStep}
title={shellTitle}
description={shellDescription}
policyTitle={policyTitle}
policyDescription={policyDescription}
addFieldExpanded={addFieldExpanded}
copy={copy}
maxChars={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
onPolicyTitleChange={setPolicyTitle}
onPolicyDescriptionChange={setPolicyDescription}
onPressAddCustomField={() => setAddFieldExpanded(true)}
onSelectFieldType={handleSelectFieldType}
fieldTypeModal={fieldTypeModal}
fieldBodiesCopy={fieldBodiesCopy}
fieldBodiesProps={{
textBlockTitle,
textPlaceholderBody,
onTextBlockTitleChange: setTextBlockTitle,
onTextPlaceholderBodyChange: setTextPlaceholderBody,
badgeBlockTitle,
badgeOptions,
onBadgeBlockTitleChange: setBadgeBlockTitle,
onBadgeAddOption: handleBadgeAddOption,
uploadBlockTitle,
onUploadBlockTitleChange: setUploadBlockTitle,
fileInputRef,
onFileChosen: handleFileChosen,
proportionBlockTitle,
proportionDefault,
onProportionBlockTitleChange: setProportionBlockTitle,
onProportionDefaultChange: setProportionDefault,
}}
nextDisabled={shellNextDisabled}
nextLabel={nextLabel}
showBackButton
onBack={handleBack}
onNext={handleNext}
stepper={!fieldTypeModal}
/>
);
},
);
CustomMethodCardWizardContainer.displayName = "CustomMethodCardWizard";
export default CustomMethodCardWizardContainer;
@@ -0,0 +1,120 @@
import type { RefObject } from "react";
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
export interface CustomMethodCardWizardFieldBodiesCopy {
requiredHint: string;
text: {
blockTitleLabel: string;
blockTitlePlaceholder: string;
placeholderLabel: string;
placeholderFieldPlaceholder: string;
};
badges: {
blockTitleLabel: string;
blockTitlePlaceholder: string;
optionsLabel: string;
addOptionLabel: string;
};
upload: {
blockTitleLabel: string;
blockTitlePlaceholder: string;
uploadFileInputAriaLabel: string;
uploadHint: string;
};
proportion: {
blockTitleLabel: string;
blockTitlePlaceholder: string;
defaultLabel: string;
decrementAriaLabel: string;
incrementAriaLabel: string;
};
}
export interface CustomMethodCardWizardCopy {
step1: { title: string; description: string; fieldPlaceholder: string };
step2: { title: string; description: string; fieldPlaceholder: string };
step3: { title: string; description: string };
footerFinalize: string;
fieldModals: {
addField: string;
requiredHint: string;
text: CustomMethodCardWizardFieldBodiesCopy["text"] & {
title: string;
description: string;
};
badges: CustomMethodCardWizardFieldBodiesCopy["badges"] & {
title: string;
description: string;
};
upload: CustomMethodCardWizardFieldBodiesCopy["upload"] & {
title: string;
description: string;
};
proportion: CustomMethodCardWizardFieldBodiesCopy["proportion"] & {
title: string;
description: string;
};
};
}
export interface CustomMethodCardWizardProps {
isOpen: boolean;
onClose: () => void;
/** Called when the user completes step 3; parent assigns id and persists state. */
onFinalize: (payload: {
title: string;
description: string;
fieldBlocks: CustomMethodCardFieldBlock[];
}) => void;
}
export interface CustomMethodCardWizardFieldBodiesViewProps {
fieldType: AddCustomFieldType;
copy: CustomMethodCardWizardFieldBodiesCopy;
textBlockTitle: string;
textPlaceholderBody: string;
onTextBlockTitleChange: (_v: string) => void;
onTextPlaceholderBodyChange: (_v: string) => void;
badgeBlockTitle: string;
badgeOptions: string[];
onBadgeBlockTitleChange: (_v: string) => void;
onBadgeAddOption: (_v: string) => void;
uploadBlockTitle: string;
onUploadBlockTitleChange: (_v: string) => void;
fileInputRef: RefObject<HTMLInputElement | null>;
onFileChosen: (e: React.ChangeEvent<HTMLInputElement>) => void;
proportionBlockTitle: string;
proportionDefault: number;
onProportionBlockTitleChange: (_v: string) => void;
onProportionDefaultChange: (_v: number) => void;
}
export interface CustomMethodCardWizardViewProps {
isOpen: boolean;
onDismiss: () => void;
wizardStep: 1 | 2 | 3;
title: string;
description: string;
policyTitle: string;
policyDescription: string;
addFieldExpanded: boolean;
copy: CustomMethodCardWizardCopy;
maxChars: number;
onPolicyTitleChange: (v: string) => void;
onPolicyDescriptionChange: (v: string) => void;
onPressAddCustomField: () => void;
onSelectFieldType: (t: AddCustomFieldType) => void;
fieldTypeModal: AddCustomFieldType | null;
fieldBodiesCopy: CustomMethodCardWizardFieldBodiesCopy;
fieldBodiesProps: Omit<
CustomMethodCardWizardFieldBodiesViewProps,
"fieldType" | "copy"
>;
nextDisabled: boolean;
nextLabel: string;
showBackButton: boolean;
onBack: () => void;
onNext: () => void;
stepper: boolean;
}
@@ -0,0 +1,95 @@
"use client";
import { memo } from "react";
import Create from "../../../../components/modals/Create";
import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea";
import AddCustomField from "../../../../components/controls/AddCustomField";
import { CustomMethodCardWizardFieldBodiesView } from "./CustomMethodCardWizardFieldBodies.view";
import type { CustomMethodCardWizardViewProps } from "./CustomMethodCardWizard.types";
function CustomMethodCardWizardViewComponent({
isOpen,
onDismiss,
wizardStep,
title,
description,
policyTitle,
policyDescription,
addFieldExpanded,
copy,
maxChars,
onPolicyTitleChange,
onPolicyDescriptionChange,
onPressAddCustomField,
onSelectFieldType,
fieldTypeModal,
fieldBodiesCopy,
fieldBodiesProps,
nextDisabled,
nextLabel,
showBackButton,
onBack,
onNext,
stepper,
}: CustomMethodCardWizardViewProps) {
return (
<Create
isOpen={isOpen}
onClose={onDismiss}
title={title}
description={description}
showBackButton={showBackButton}
showNextButton
onBack={onBack}
onNext={onNext}
nextButtonText={nextLabel}
nextButtonDisabled={nextDisabled}
currentStep={wizardStep}
totalSteps={3}
stepper={stepper}
backdropVariant="blurredYellow"
>
{fieldTypeModal ? (
<CustomMethodCardWizardFieldBodiesView
fieldType={fieldTypeModal}
copy={fieldBodiesCopy}
{...fieldBodiesProps}
/>
) : null}
{!fieldTypeModal && wizardStep === 1 ? (
<InputWithCounter
placeholder={copy.step1.fieldPlaceholder}
value={policyTitle}
onChange={onPolicyTitleChange}
maxLength={maxChars}
/>
) : null}
{!fieldTypeModal && wizardStep === 2 ? (
<TextArea
appearance="default"
formHeader={false}
placeholder={copy.step2.fieldPlaceholder}
value={policyDescription}
maxLength={maxChars}
onChange={(e) => onPolicyDescriptionChange(e.target.value)}
textHint={`${policyDescription.length}/${maxChars}`}
className="w-full"
rows={4}
/>
) : null}
{!fieldTypeModal && wizardStep === 3 ? (
<AddCustomField
active={addFieldExpanded}
onPressAdd={onPressAddCustomField}
onSelectFieldType={onSelectFieldType}
/>
) : null}
</Create>
);
}
export const CustomMethodCardWizardView = memo(
CustomMethodCardWizardViewComponent,
);
CustomMethodCardWizardView.displayName = "CustomMethodCardWizardView";
@@ -0,0 +1,161 @@
"use client";
import { memo } from "react";
import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea";
import TextInput from "../../../../components/controls/TextInput";
import Upload from "../../../../components/controls/Upload";
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import InputLabel from "../../../../components/type/InputLabel";
import ApplicableScopeField from "../ApplicableScopeField";
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
import type { CustomMethodCardWizardFieldBodiesViewProps } from "./CustomMethodCardWizard.types";
const TEXT_PLACEHOLDER_MAX = 8000;
function CustomMethodCardWizardFieldBodiesViewComponent({
fieldType,
copy,
textBlockTitle,
textPlaceholderBody,
onTextBlockTitleChange,
onTextPlaceholderBodyChange,
badgeBlockTitle,
badgeOptions,
onBadgeBlockTitleChange,
onBadgeAddOption,
uploadBlockTitle,
onUploadBlockTitleChange,
fileInputRef,
onFileChosen,
proportionBlockTitle,
proportionDefault,
onProportionBlockTitleChange,
onProportionDefaultChange,
}: CustomMethodCardWizardFieldBodiesViewProps) {
if (fieldType === "text") {
return (
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
<InputWithCounter
label={copy.text.blockTitleLabel}
placeholder={copy.text.blockTitlePlaceholder}
value={textBlockTitle}
onChange={onTextBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/>
<div className="flex flex-col gap-2">
<InputLabel
label={copy.text.placeholderLabel}
helpIcon
size="s"
palette="default"
/>
<TextArea
formHeader={false}
appearance="embedded"
value={textPlaceholderBody}
onChange={(e) => onTextPlaceholderBodyChange(e.target.value)}
maxLength={TEXT_PLACEHOLDER_MAX}
placeholder={copy.text.placeholderFieldPlaceholder}
textHint={`${textPlaceholderBody.length}/${TEXT_PLACEHOLDER_MAX}`}
className="w-full"
rows={3}
/>
</div>
</div>
);
}
if (fieldType === "badges") {
return (
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
<div className="flex flex-col gap-2">
<InputLabel
label={copy.badges.blockTitleLabel}
helpIcon
helperText={copy.requiredHint}
size="s"
palette="default"
/>
<TextInput
formHeader={false}
placeholder={copy.badges.blockTitlePlaceholder}
value={badgeBlockTitle}
onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon={false}
/>
</div>
<ApplicableScopeField
label={copy.badges.optionsLabel}
addLabel={copy.badges.addOptionLabel}
scopes={badgeOptions}
selectedScopes={badgeOptions}
onToggleScope={() => {
/* product: all badge options stay selected */
}}
onAddScope={onBadgeAddOption}
/>
</div>
);
}
if (fieldType === "upload") {
return (
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
<input
ref={fileInputRef}
type="file"
className="sr-only"
tabIndex={-1}
aria-label={copy.upload.uploadFileInputAriaLabel}
onChange={onFileChosen}
/>
<InputWithCounter
label={copy.upload.blockTitleLabel}
placeholder={copy.upload.blockTitlePlaceholder}
value={uploadBlockTitle}
onChange={onUploadBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/>
<Upload
hintText={copy.upload.uploadHint}
onClick={() => fileInputRef.current?.click()}
/>
</div>
);
}
return (
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
<InputWithCounter
label={copy.proportion.blockTitleLabel}
placeholder={copy.proportion.blockTitlePlaceholder}
value={proportionBlockTitle}
onChange={onProportionBlockTitleChange}
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
showHelpIcon
/>
<IncrementerBlock
label={copy.proportion.defaultLabel}
value={proportionDefault}
min={1}
max={100}
step={1}
onChange={onProportionDefaultChange}
formatValue={(v) => `${v}%`}
decrementAriaLabel={copy.proportion.decrementAriaLabel}
incrementAriaLabel={copy.proportion.incrementAriaLabel}
blockClassName="w-full"
/>
</div>
);
}
export const CustomMethodCardWizardFieldBodiesView = memo(
CustomMethodCardWizardFieldBodiesViewComponent,
);
CustomMethodCardWizardFieldBodiesView.displayName =
"CustomMethodCardWizardFieldBodiesView";
@@ -0,0 +1,2 @@
export { default } from "./CustomMethodCardWizard.container";
export type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types";
@@ -34,6 +34,7 @@ import {
DecisionApproachEditFields,
MembershipMethodEditFields,
} from "./methodEditFields";
import CustomMethodCardModalBody from "./CustomMethodCardModalBody";
import {
communicationPresetFor,
conflictManagementPresetFor,
@@ -41,6 +42,8 @@ import {
decisionApproachPresetFor,
membershipPresetFor,
} from "../../../../lib/create/finalReviewChipPresets";
import { isCustomMethodCardId } from "../../../../lib/create/isCustomMethodCardId";
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
import type {
CommunicationMethodDetailEntry,
ConflictManagementDetailEntry,
@@ -66,21 +69,25 @@ export type FinalReviewChipEditPatch =
groupKey: "communication";
overrideKey: string;
value: CommunicationMethodDetailEntry;
customMethodCardFieldBlocks?: CustomMethodCardFieldBlock[];
}
| {
groupKey: "membership";
overrideKey: string;
value: MembershipMethodDetailEntry;
customMethodCardFieldBlocks?: CustomMethodCardFieldBlock[];
}
| {
groupKey: "decisionApproaches";
overrideKey: string;
value: DecisionApproachDetailEntry;
customMethodCardFieldBlocks?: CustomMethodCardFieldBlock[];
}
| {
groupKey: "conflictManagement";
overrideKey: string;
value: ConflictManagementDetailEntry;
customMethodCardFieldBlocks?: CustomMethodCardFieldBlock[];
};
export interface FinalReviewChipEditModalProps {
@@ -128,12 +135,16 @@ export function FinalReviewChipEditModal({
);
const [draft, setDraft] = useState<Draft | null>(null);
const [fieldBlocksDraft, setFieldBlocksDraft] = useState<
CustomMethodCardFieldBlock[] | null
>(null);
/**
* JSON-stringified seed used for the cheap dirty check. Re-captured on
* every (re)open so reopening a chip after a save shows Save-disabled
* again until the user makes a fresh edit.
*/
const initialSnapshotRef = useRef<string>("");
const initialFieldBlocksSnapshotRef = useRef<string>("");
const seededTargetRef = useRef<string | null>(null);
useEffect(() => {
@@ -144,6 +155,18 @@ export function FinalReviewChipEditModal({
const seed = seedDraftForTarget(target, state);
setDraft(seed);
initialSnapshotRef.current = JSON.stringify(seed.value);
if (
target.groupKey !== "coreValues" &&
isCustomMethodCardId(target.overrideKey, state.customMethodCardMetaById)
) {
const blocks =
state.customMethodCardFieldBlocksById?.[target.overrideKey] ?? [];
setFieldBlocksDraft(blocks);
initialFieldBlocksSnapshotRef.current = JSON.stringify(blocks);
} else {
setFieldBlocksDraft(null);
initialFieldBlocksSnapshotRef.current = "";
}
seededTargetRef.current = targetKey;
}, [isOpen, target, state]);
@@ -152,24 +175,86 @@ export function FinalReviewChipEditModal({
}, [isOpen]);
const isDirty = useMemo(() => {
if (!draft) return false;
return JSON.stringify(draft.value) !== initialSnapshotRef.current;
}, [draft]);
if (!draft || !target) return false;
const valueDirty =
JSON.stringify(draft.value) !== initialSnapshotRef.current;
const customMethod =
target.groupKey !== "coreValues" &&
isCustomMethodCardId(target.overrideKey, state.customMethodCardMetaById);
const blocksDirty =
customMethod &&
fieldBlocksDraft !== null &&
JSON.stringify(fieldBlocksDraft) !==
initialFieldBlocksSnapshotRef.current;
return valueDirty || Boolean(blocksDirty);
}, [draft, target, state.customMethodCardMetaById, fieldBlocksDraft]);
const handleSave = () => {
if (!target || !draft || !isDirty) return;
onSave({
groupKey: draft.groupKey,
overrideKey: target.overrideKey,
value: draft.value,
} as FinalReviewChipEditPatch);
const { overrideKey } = target;
const customBlocks =
draft.groupKey !== "coreValues" &&
isCustomMethodCardId(overrideKey, state.customMethodCardMetaById) &&
fieldBlocksDraft !== null
? fieldBlocksDraft
: undefined;
switch (draft.groupKey) {
case "coreValues":
onSave({
groupKey: "coreValues",
overrideKey,
value: draft.value,
});
break;
case "communication":
onSave({
groupKey: "communication",
overrideKey,
value: draft.value,
...(customBlocks !== undefined
? { customMethodCardFieldBlocks: customBlocks }
: {}),
});
break;
case "membership":
onSave({
groupKey: "membership",
overrideKey,
value: draft.value,
...(customBlocks !== undefined
? { customMethodCardFieldBlocks: customBlocks }
: {}),
});
break;
case "decisionApproaches":
onSave({
groupKey: "decisionApproaches",
overrideKey,
value: draft.value,
...(customBlocks !== undefined
? { customMethodCardFieldBlocks: customBlocks }
: {}),
});
break;
case "conflictManagement":
onSave({
groupKey: "conflictManagement",
overrideKey,
value: draft.value,
...(customBlocks !== undefined
? { customMethodCardFieldBlocks: customBlocks }
: {}),
});
break;
}
onClose();
};
const subtitle = useMemo(() => {
if (!target) return "";
return subtitleForTarget(target, { tCv, tComm, tMem, tDa, tCm });
}, [target, tCv, tComm, tMem, tDa, tCm]);
return subtitleForTarget(target, { tCv, tComm, tMem, tDa, tCm }, state.customMethodCardMetaById);
}, [target, tCv, tComm, tMem, tDa, tCm, state.customMethodCardMetaById]);
return (
<Create
@@ -200,36 +285,84 @@ export function FinalReviewChipEditModal({
onChange={(value) => setDraft({ groupKey: "coreValues", value })}
/>
)}
{draft?.groupKey === "communication" && (
<CommunicationMethodEditFields
value={draft.value}
onChange={(value) =>
setDraft({ groupKey: "communication", value })
}
/>
)}
{draft?.groupKey === "membership" && (
<MembershipMethodEditFields
value={draft.value}
onChange={(value) => setDraft({ groupKey: "membership", value })}
/>
)}
{draft?.groupKey === "decisionApproaches" && (
<DecisionApproachEditFields
value={draft.value}
onChange={(value) =>
setDraft({ groupKey: "decisionApproaches", value })
}
/>
)}
{draft?.groupKey === "conflictManagement" && (
<ConflictManagementEditFields
value={draft.value}
onChange={(value) =>
setDraft({ groupKey: "conflictManagement", value })
}
/>
)}
{draft?.groupKey === "communication" &&
(target &&
isCustomMethodCardId(
target.overrideKey,
state.customMethodCardMetaById,
) ? (
<CustomMethodCardModalBody
cardId={target.overrideKey}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={fieldBlocksDraft}
onFieldBlocksChange={setFieldBlocksDraft}
/>
) : (
<CommunicationMethodEditFields
value={draft.value}
onChange={(value) =>
setDraft({ groupKey: "communication", value })
}
/>
))}
{draft?.groupKey === "membership" &&
(target &&
isCustomMethodCardId(
target.overrideKey,
state.customMethodCardMetaById,
) ? (
<CustomMethodCardModalBody
cardId={target.overrideKey}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={fieldBlocksDraft}
onFieldBlocksChange={setFieldBlocksDraft}
/>
) : (
<MembershipMethodEditFields
value={draft.value}
onChange={(value) => setDraft({ groupKey: "membership", value })}
/>
))}
{draft?.groupKey === "decisionApproaches" &&
(target &&
isCustomMethodCardId(
target.overrideKey,
state.customMethodCardMetaById,
) ? (
<CustomMethodCardModalBody
cardId={target.overrideKey}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={fieldBlocksDraft}
onFieldBlocksChange={setFieldBlocksDraft}
/>
) : (
<DecisionApproachEditFields
value={draft.value}
onChange={(value) =>
setDraft({ groupKey: "decisionApproaches", value })
}
/>
))}
{draft?.groupKey === "conflictManagement" &&
(target &&
isCustomMethodCardId(
target.overrideKey,
state.customMethodCardMetaById,
) ? (
<CustomMethodCardModalBody
cardId={target.overrideKey}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={fieldBlocksDraft}
onFieldBlocksChange={setFieldBlocksDraft}
/>
) : (
<ConflictManagementEditFields
value={draft.value}
onChange={(value) =>
setDraft({ groupKey: "conflictManagement", value })
}
/>
))}
</div>
</Create>
);
@@ -309,18 +442,31 @@ type SubtitleMessages = {
function subtitleForTarget(
target: FinalReviewChipEditTarget,
msgs: SubtitleMessages,
customMeta?: CreateFlowState["customMethodCardMetaById"],
): string {
switch (target.groupKey) {
case "coreValues":
return msgs.tCv.detailModal.subtitle;
case "communication":
case "communication": {
const fromCustom = customMeta?.[target.overrideKey]?.supportText?.trim();
if (fromCustom) return fromCustom;
return findMethodSupportText(msgs.tComm.methods, target.overrideKey);
case "membership":
}
case "membership": {
const fromCustom = customMeta?.[target.overrideKey]?.supportText?.trim();
if (fromCustom) return fromCustom;
return findMethodSupportText(msgs.tMem.methods, target.overrideKey);
case "decisionApproaches":
}
case "decisionApproaches": {
const fromCustom = customMeta?.[target.overrideKey]?.supportText?.trim();
if (fromCustom) return fromCustom;
return findMethodSupportText(msgs.tDa.methods, target.overrideKey);
case "conflictManagement":
}
case "conflictManagement": {
const fromCustom = customMeta?.[target.overrideKey]?.supportText?.trim();
if (fromCustom) return fromCustom;
return findMethodSupportText(msgs.tCm.methods, target.overrideKey);
}
}
}
@@ -0,0 +1,29 @@
"use client";
import { useCallback } from "react";
import { useCreateFlow } from "../context/CreateFlowContext";
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
/**
* Stable writer for `customMethodCardFieldBlocksById[id]` used from facet card
* modals. Uses {@link replaceState} so merges read the latest draft (no stale
* closure over `customMethodCardFieldBlocksById`).
*/
export function useCustomMethodCardFieldBlocksChange(cardId: string | null) {
const { replaceState, markCreateFlowInteraction } = useCreateFlow();
return useCallback(
(nextBlocks: CustomMethodCardFieldBlock[]) => {
if (!cardId) return;
markCreateFlowInteraction();
replaceState((prev) => ({
...prev,
customMethodCardFieldBlocksById: {
...(prev.customMethodCardFieldBlocksById ?? {}),
[cardId]: nextBlocks,
},
}));
},
[cardId, markCreateFlowInteraction, replaceState],
);
}
@@ -1,8 +1,6 @@
"use client";
import { useMemo } from "react";
import { useCreateFlow } from "../context/CreateFlowContext";
import type { CreateFlowMethodCardFacetSection } from "../types";
import {
mergeCompactCardIdsWithPinnedSelected,
orderRankedMethodsWithPinnedSelection,
@@ -17,38 +15,33 @@ import {
type MethodEntry = { id: string; label: string; supportText: string };
/**
* Applies score ranking, compact-slot rules, optional “pinned selection” showcase
* order. Rows stay pinned across navigation while `methodSectionsPinCommitted` is true
* and the section still has selections; we do **not** clear the flag when selection
* arrays briefly go empty during draft hydration (`replaceState` / merge flashes) —
* display order already ignores the pin until `pinActive` is true again.
* Applies score ranking, compact-slot rules, then surfaces selected ids first in
* `selected*Ids` order (most-recent add at index 0 via
* {@link moveFacetSelectionIdToFront}). Selection-first applies whenever the facet
* has any selection — not only after footer Confirm (`methodSectionsPinCommitted`).
*/
export function useMethodCardDeckOrdering(
section: RecommendationSection,
methods: readonly MethodEntry[],
selectedIds: readonly string[],
) {
const { state } = useCreateFlow();
const facetKey = section as CreateFlowMethodCardFacetSection;
const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section);
const pinStored =
state.methodSectionsPinCommitted?.[facetKey] === true;
const pinActive = Boolean(pinStored && selectedIds.length > 0);
const rankedMethods = useMemo(
() => rankMethodsByScore(methods, scoresBySlug),
[methods, scoresBySlug],
);
const selectionShowcaseActive = selectedIds.length > 0;
const displayMethods = useMemo(
() =>
orderRankedMethodsWithPinnedSelection(
rankedMethods,
selectedIds,
pinActive,
selectionShowcaseActive,
),
[rankedMethods, selectedIds, pinActive],
[rankedMethods, selectedIds, selectionShowcaseActive],
);
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
@@ -68,10 +61,10 @@ export function useMethodCardDeckOrdering(
displayMethods.map((m) => m.id),
baseCompactCardIds,
selectedIds,
pinActive,
selectionShowcaseActive,
5,
),
[displayMethods, baseCompactCardIds, selectedIds, pinActive],
[displayMethods, baseCompactCardIds, selectedIds, selectionShowcaseActive],
);
const sampleCards = useMemo(
@@ -8,13 +8,14 @@
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
* reuse `CardStack` / `CreateFlowStepShell` as needed.
*
* Card click opens the Figma "Add Platform" create modal (node `20246-15829`) with three
* editable sections rendered by {@link CommunicationMethodEditFields}. The same field set is
* reused on `/create/final-review` — see `FinalReviewChipEditModal`. Confirm persists both
* the chip selection and any user edits as a `communicationMethodDetailsById[id]` override.
* 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}.
*/
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
@@ -29,8 +30,16 @@ import {
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { CommunicationMethodEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { communicationPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
import type { CommunicationMethodDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { useCustomMethodCardFieldBlocksChange } from "../../hooks/useCustomMethodCardFieldBlocksChange";
export function CommunicationMethodsScreen() {
const m = useMessages();
@@ -42,28 +51,45 @@ export function CommunicationMethodsScreen() {
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const [pendingDraft, setPendingDraft] =
useState<CommunicationMethodDetailEntry | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const selectedIds = state.selectedCommunicationMethodIds ?? [];
const mergedMethods = useMemo(
() =>
mergePresetMethodsWithCustom(
comm.methods,
selectedIds,
state.customMethodCardMetaById,
),
[comm.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
"communication",
comm.methods,
mergedMethods,
selectedIds,
);
const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction();
setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]);
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
const description = expanded ? (
comm.page.expandedDescription
<>
{comm.page.expandedDescriptionBefore}
<InlineTextButton onClick={handleOpenAddWizard}>
{comm.page.compactDescriptionLinkLabel}
</InlineTextButton>
{comm.page.expandedDescriptionAfter}
</>
) : (
<>
{comm.page.compactDescriptionBefore}
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
<InlineTextButton onClick={handleOpenAddWizard}>
{comm.page.compactDescriptionLinkLabel}
</InlineTextButton>
{comm.page.compactDescriptionAfter}
@@ -73,10 +99,13 @@ 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: comm.addPlatform.nextButtonText,
nextButtonText: alreadySelected
? comm.removePlatform.nextButtonText
: comm.addPlatform.nextButtonText,
};
})()
: {
@@ -114,22 +143,87 @@ export function CommunicationMethodsScreen() {
[markCreateFlowInteraction],
);
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
createModalOpen ? pendingCardId : null,
);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
setPendingCardId(null);
setPendingDraft(null);
}, []);
const handleCreateModalConfirm = useCallback(() => {
if (!pendingCardId || !pendingDraft) {
const handleCloseAddWizard = useCallback(() => {
setAddCustomWizardOpen(false);
}, []);
const handleFinalizeCustomCard = useCallback(
({
title,
description,
fieldBlocks,
}: {
title: string;
description: string;
fieldBlocks: CustomMethodCardFieldBlock[];
}) => {
markCreateFlowInteraction();
const id = crypto.randomUUID();
updateState({
selectedCommunicationMethodIds: moveFacetSelectionIdToFront(
selectedIds,
id,
),
customMethodCardMetaById: {
...(state.customMethodCardMetaById ?? {}),
[id]: { label: title, supportText: description },
},
communicationMethodDetailsById: {
...(state.communicationMethodDetailsById ?? {}),
[id]: communicationPresetFor(id),
},
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[id]: fieldBlocks,
},
});
},
[
markCreateFlowInteraction,
selectedIds,
state.communicationMethodDetailsById,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
updateState,
],
);
const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) {
handleCreateModalClose();
return;
}
markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) {
updateState(
removeMethodCardFromFacetSelection(
state,
"communication",
pendingCardId,
),
);
handleCreateModalClose();
return;
}
if (!pendingDraft) {
handleCreateModalClose();
return;
}
updateState({
selectedCommunicationMethodIds: selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
selectedCommunicationMethodIds: moveFacetSelectionIdToFront(
selectedIds,
pendingCardId,
),
communicationMethodDetailsById: {
...(state.communicationMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
@@ -142,11 +236,12 @@ export function CommunicationMethodsScreen() {
pendingCardId,
pendingDraft,
selectedIds,
state.communicationMethodDetailsById,
state,
updateState,
]);
return (
<>
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
@@ -182,7 +277,7 @@ export function CommunicationMethodsScreen() {
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalConfirm}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
@@ -190,13 +285,31 @@ export function CommunicationMethodsScreen() {
backdropVariant="blurredYellow"
>
{pendingCardId && pendingDraft ? (
<CommunicationMethodEditFields
key={pendingCardId}
value={pendingDraft}
onChange={handleDraftChange}
/>
isCustomMethodCardId(
pendingCardId,
state.customMethodCardMetaById,
) ? (
<CustomMethodCardModalBody
key={pendingCardId}
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
onFieldBlocksChange={onCustomFieldBlocksChange}
/>
) : (
<CommunicationMethodEditFields
key={pendingCardId}
value={pendingDraft}
onChange={handleDraftChange}
/>
)
) : null}
</Create>
</CreateFlowStepShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
onFinalize={handleFinalizeCustomCard}
/>
</>
);
}
@@ -12,7 +12,7 @@
* any user edits as a `conflictManagementDetailsById[id]` override.
*/
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
@@ -27,8 +27,16 @@ import {
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { ConflictManagementEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { conflictManagementPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
import type { ConflictManagementDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { useCustomMethodCardFieldBlocksChange } from "../../hooks/useCustomMethodCardFieldBlocksChange";
export function ConflictManagementScreen() {
const m = useMessages();
@@ -40,28 +48,45 @@ export function ConflictManagementScreen() {
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const [pendingDraft, setPendingDraft] =
useState<ConflictManagementDetailEntry | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const selectedIds = state.selectedConflictManagementIds ?? [];
const mergedMethods = useMemo(
() =>
mergePresetMethodsWithCustom(
cm.methods,
selectedIds,
state.customMethodCardMetaById,
),
[cm.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
"conflictManagement",
cm.methods,
mergedMethods,
selectedIds,
);
const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction();
setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]);
const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
const description = expanded ? (
cm.page.expandedDescription
<>
{cm.page.expandedDescriptionBefore}
<InlineTextButton onClick={handleOpenAddWizard}>
{cm.page.compactDescriptionLinkLabel}
</InlineTextButton>
{cm.page.expandedDescriptionAfter}
</>
) : (
<>
{cm.page.compactDescriptionBefore}
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
<InlineTextButton onClick={handleOpenAddWizard}>
{cm.page.compactDescriptionLinkLabel}
</InlineTextButton>
{cm.page.compactDescriptionAfter}
@@ -71,10 +96,13 @@ 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: cm.addApproach.nextButtonText,
nextButtonText: alreadySelected
? cm.removeApproach.nextButtonText
: cm.addApproach.nextButtonText,
};
})()
: {
@@ -116,22 +144,87 @@ export function ConflictManagementScreen() {
[markCreateFlowInteraction],
);
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
createModalOpen ? pendingCardId : null,
);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
setPendingCardId(null);
setPendingDraft(null);
}, []);
const handleCreateModalConfirm = useCallback(() => {
if (!pendingCardId || !pendingDraft) {
const handleCloseAddWizard = useCallback(() => {
setAddCustomWizardOpen(false);
}, []);
const handleFinalizeCustomCard = useCallback(
({
title,
description,
fieldBlocks,
}: {
title: string;
description: string;
fieldBlocks: CustomMethodCardFieldBlock[];
}) => {
markCreateFlowInteraction();
const id = crypto.randomUUID();
updateState({
selectedConflictManagementIds: moveFacetSelectionIdToFront(
selectedIds,
id,
),
customMethodCardMetaById: {
...(state.customMethodCardMetaById ?? {}),
[id]: { label: title, supportText: description },
},
conflictManagementDetailsById: {
...(state.conflictManagementDetailsById ?? {}),
[id]: conflictManagementPresetFor(id),
},
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[id]: fieldBlocks,
},
});
},
[
markCreateFlowInteraction,
selectedIds,
state.conflictManagementDetailsById,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
updateState,
],
);
const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) {
handleCreateModalClose();
return;
}
markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) {
updateState(
removeMethodCardFromFacetSelection(
state,
"conflictManagement",
pendingCardId,
),
);
handleCreateModalClose();
return;
}
if (!pendingDraft) {
handleCreateModalClose();
return;
}
updateState({
selectedConflictManagementIds: selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
selectedConflictManagementIds: moveFacetSelectionIdToFront(
selectedIds,
pendingCardId,
),
conflictManagementDetailsById: {
...(state.conflictManagementDetailsById ?? {}),
[pendingCardId]: pendingDraft,
@@ -144,11 +237,12 @@ export function ConflictManagementScreen() {
pendingCardId,
pendingDraft,
selectedIds,
state.conflictManagementDetailsById,
state,
updateState,
]);
return (
<>
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
@@ -184,7 +278,7 @@ export function ConflictManagementScreen() {
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalConfirm}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
@@ -192,13 +286,31 @@ export function ConflictManagementScreen() {
backdropVariant="blurredYellow"
>
{pendingCardId && pendingDraft ? (
<ConflictManagementEditFields
key={pendingCardId}
value={pendingDraft}
onChange={handleDraftChange}
/>
isCustomMethodCardId(
pendingCardId,
state.customMethodCardMetaById,
) ? (
<CustomMethodCardModalBody
key={pendingCardId}
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
onFieldBlocksChange={onCustomFieldBlocksChange}
/>
) : (
<ConflictManagementEditFields
key={pendingCardId}
value={pendingDraft}
onChange={handleDraftChange}
/>
)
) : null}
</Create>
</CreateFlowStepShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
onFinalize={handleFinalizeCustomCard}
/>
</>
);
}
@@ -13,7 +13,7 @@
* DB-driven content.
*/
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
@@ -28,8 +28,16 @@ import {
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import { MembershipMethodEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { membershipPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
import type { MembershipMethodDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { useCustomMethodCardFieldBlocksChange } from "../../hooks/useCustomMethodCardFieldBlocksChange";
export function MembershipMethodsScreen() {
const m = useMessages();
@@ -41,28 +49,45 @@ export function MembershipMethodsScreen() {
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const [pendingDraft, setPendingDraft] =
useState<MembershipMethodDetailEntry | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const selectedIds = state.selectedMembershipMethodIds ?? [];
const mergedMethods = useMemo(
() =>
mergePresetMethodsWithCustom(
mem.methods,
selectedIds,
state.customMethodCardMetaById,
),
[mem.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
"membership",
mem.methods,
mergedMethods,
selectedIds,
);
const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction();
setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]);
const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
const description = expanded ? (
mem.page.expandedDescription
<>
{mem.page.expandedDescriptionBefore}
<InlineTextButton onClick={handleOpenAddWizard}>
{mem.page.compactDescriptionLinkLabel}
</InlineTextButton>
{mem.page.expandedDescriptionAfter}
</>
) : (
<>
{mem.page.compactDescriptionBefore}
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
<InlineTextButton onClick={handleOpenAddWizard}>
{mem.page.compactDescriptionLinkLabel}
</InlineTextButton>
{mem.page.compactDescriptionAfter}
@@ -72,10 +97,13 @@ 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: mem.addPlatform.nextButtonText,
nextButtonText: alreadySelected
? mem.removePlatform.nextButtonText
: mem.addPlatform.nextButtonText,
};
})()
: {
@@ -113,22 +141,83 @@ export function MembershipMethodsScreen() {
[markCreateFlowInteraction],
);
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
createModalOpen ? pendingCardId : null,
);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
setPendingCardId(null);
setPendingDraft(null);
}, []);
const handleCreateModalConfirm = useCallback(() => {
if (!pendingCardId || !pendingDraft) {
const handleCloseAddWizard = useCallback(() => {
setAddCustomWizardOpen(false);
}, []);
const handleFinalizeCustomCard = useCallback(
({
title,
description,
fieldBlocks,
}: {
title: string;
description: string;
fieldBlocks: CustomMethodCardFieldBlock[];
}) => {
markCreateFlowInteraction();
const id = crypto.randomUUID();
updateState({
selectedMembershipMethodIds: moveFacetSelectionIdToFront(
selectedIds,
id,
),
customMethodCardMetaById: {
...(state.customMethodCardMetaById ?? {}),
[id]: { label: title, supportText: description },
},
membershipMethodDetailsById: {
...(state.membershipMethodDetailsById ?? {}),
[id]: membershipPresetFor(id),
},
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[id]: fieldBlocks,
},
});
},
[
markCreateFlowInteraction,
selectedIds,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
state.membershipMethodDetailsById,
updateState,
],
);
const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) {
handleCreateModalClose();
return;
}
markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) {
updateState(
removeMethodCardFromFacetSelection(state, "membership", pendingCardId),
);
handleCreateModalClose();
return;
}
if (!pendingDraft) {
handleCreateModalClose();
return;
}
updateState({
selectedMembershipMethodIds: selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
selectedMembershipMethodIds: moveFacetSelectionIdToFront(
selectedIds,
pendingCardId,
),
membershipMethodDetailsById: {
...(state.membershipMethodDetailsById ?? {}),
[pendingCardId]: pendingDraft,
@@ -141,11 +230,12 @@ export function MembershipMethodsScreen() {
pendingCardId,
pendingDraft,
selectedIds,
state.membershipMethodDetailsById,
state,
updateState,
]);
return (
<>
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
@@ -181,7 +271,7 @@ export function MembershipMethodsScreen() {
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalConfirm}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
@@ -189,13 +279,31 @@ export function MembershipMethodsScreen() {
backdropVariant="blurredYellow"
>
{pendingCardId && pendingDraft ? (
<MembershipMethodEditFields
key={pendingCardId}
value={pendingDraft}
onChange={handleDraftChange}
/>
isCustomMethodCardId(
pendingCardId,
state.customMethodCardMetaById,
) ? (
<CustomMethodCardModalBody
key={pendingCardId}
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
onFieldBlocksChange={onCustomFieldBlocksChange}
/>
) : (
<MembershipMethodEditFields
key={pendingCardId}
value={pendingDraft}
onChange={handleDraftChange}
/>
)
) : null}
</Create>
</CreateFlowStepShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
onFinalize={handleFinalizeCustomCard}
/>
</>
);
}
@@ -29,8 +29,16 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { DecisionApproachEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
import { moveFacetSelectionIdToFront } from "../../../../../lib/create/methodCardSelectionOrder";
import { isCustomMethodCardId } from "../../../../../lib/create/isCustomMethodCardId";
import { removeMethodCardFromFacetSelection } from "../../../../../lib/create/removeMethodCardFromFacetSelection";
import type { DecisionApproachDetailEntry } from "../../types";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { useCustomMethodCardFieldBlocksChange } from "../../hooks/useCustomMethodCardFieldBlocksChange";
export function DecisionApproachesScreen() {
const m = useMessages();
@@ -45,6 +53,7 @@ export function DecisionApproachesScreen() {
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const [pendingDraft, setPendingDraft] =
useState<DecisionApproachDetailEntry | null>(null);
const [addCustomWizardOpen, setAddCustomWizardOpen] = useState(false);
const selectedIds = state.selectedDecisionApproachIds ?? [];
@@ -57,21 +66,31 @@ export function DecisionApproachesScreen() {
[da.messageBox.items],
);
const mergedMethods = useMemo(
() =>
mergePresetMethodsWithCustom(
da.methods,
selectedIds,
state.customMethodCardMetaById,
),
[da.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
"decisionApproaches",
da.methods,
mergedMethods,
selectedIds,
);
const handleOpenAddWizard = useCallback(() => {
markCreateFlowInteraction();
setAddCustomWizardOpen(true);
}, [markCreateFlowInteraction]);
const sidebarDescription = (
<>
{da.sidebar.descriptionBefore}
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
<InlineTextButton onClick={handleOpenAddWizard}>
{da.sidebar.descriptionLinkLabel}
</InlineTextButton>
{da.sidebar.descriptionAfter}
@@ -121,6 +140,10 @@ export function DecisionApproachesScreen() {
[markCreateFlowInteraction],
);
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
createModalOpen ? pendingCardId : null,
);
const handleToggleExpand = useCallback(() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
@@ -132,16 +155,77 @@ export function DecisionApproachesScreen() {
setPendingDraft(null);
}, []);
const handleCreateModalConfirm = useCallback(() => {
if (!pendingCardId || !pendingDraft) {
const handleCloseAddWizard = useCallback(() => {
setAddCustomWizardOpen(false);
}, []);
const handleFinalizeCustomCard = useCallback(
({
title,
description,
fieldBlocks,
}: {
title: string;
description: string;
fieldBlocks: CustomMethodCardFieldBlock[];
}) => {
markCreateFlowInteraction();
const id = crypto.randomUUID();
updateState({
selectedDecisionApproachIds: moveFacetSelectionIdToFront(
selectedIds,
id,
),
customMethodCardMetaById: {
...(state.customMethodCardMetaById ?? {}),
[id]: { label: title, supportText: description },
},
decisionApproachDetailsById: {
...(state.decisionApproachDetailsById ?? {}),
[id]: decisionApproachPresetFor(id),
},
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[id]: fieldBlocks,
},
});
},
[
markCreateFlowInteraction,
selectedIds,
state.customMethodCardFieldBlocksById,
state.customMethodCardMetaById,
state.decisionApproachDetailsById,
updateState,
],
);
const handleCreateModalPrimary = useCallback(() => {
if (!pendingCardId) {
handleCreateModalClose();
return;
}
markCreateFlowInteraction();
if (selectedIds.includes(pendingCardId)) {
updateState(
removeMethodCardFromFacetSelection(
state,
"decisionApproaches",
pendingCardId,
),
);
handleCreateModalClose();
return;
}
if (!pendingDraft) {
handleCreateModalClose();
return;
}
updateState({
selectedDecisionApproachIds: selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
selectedDecisionApproachIds: moveFacetSelectionIdToFront(
selectedIds,
pendingCardId,
),
decisionApproachDetailsById: {
...(state.decisionApproachDetailsById ?? {}),
[pendingCardId]: pendingDraft,
@@ -154,17 +238,20 @@ export function DecisionApproachesScreen() {
pendingCardId,
pendingDraft,
selectedIds,
state.decisionApproachDetailsById,
state,
updateState,
]);
const modalConfig = pendingCardId
? (() => {
const method = methodById.get(pendingCardId);
const alreadySelected = selectedIds.includes(pendingCardId);
return {
title: method?.label ?? da.confirmModal.title,
description: method?.supportText ?? da.confirmModal.description,
nextButtonText: da.addApproach.nextButtonText,
nextButtonText: alreadySelected
? da.removeApproach.nextButtonText
: da.addApproach.nextButtonText,
};
})()
: {
@@ -174,6 +261,7 @@ export function DecisionApproachesScreen() {
};
return (
<>
<CreateFlowTwoColumnSelectShell
contentTopBelowMd="space-800"
lgVerticalAlign="start"
@@ -205,7 +293,19 @@ export function DecisionApproachesScreen() {
toggleLabel={da.cardStack.toggleSeeAll}
showLessLabel={da.cardStack.toggleShowLess}
title=""
description=""
description={
expanded ? (
<>
{da.cardStack.expandedStackDescriptionBefore}
<InlineTextButton onClick={handleOpenAddWizard}>
{da.sidebar.descriptionLinkLabel}
</InlineTextButton>
{da.cardStack.expandedStackDescriptionAfter}
</>
) : (
""
)
}
layout="singleStack"
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
@@ -217,7 +317,7 @@ export function DecisionApproachesScreen() {
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalConfirm}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
@@ -225,13 +325,31 @@ export function DecisionApproachesScreen() {
backdropVariant="blurredYellow"
>
{pendingCardId && pendingDraft ? (
<DecisionApproachEditFields
key={pendingCardId}
value={pendingDraft}
onChange={handleDraftChange}
/>
isCustomMethodCardId(
pendingCardId,
state.customMethodCardMetaById,
) ? (
<CustomMethodCardModalBody
key={pendingCardId}
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
onFieldBlocksChange={onCustomFieldBlocksChange}
/>
) : (
<DecisionApproachEditFields
key={pendingCardId}
value={pendingDraft}
onChange={handleDraftChange}
/>
)
) : null}
</Create>
</CreateFlowTwoColumnSelectShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
onFinalize={handleFinalizeCustomCard}
/>
</>
);
}
+14
View File
@@ -5,6 +5,7 @@
* including step types, state management, and context interfaces.
*/
import type { CustomMethodCardFieldBlock } from "../../../lib/create/customMethodCardFieldBlocks";
import type { MethodFacetApiSectionId } from "../../../lib/create/customRuleFacets";
/**
@@ -162,6 +163,19 @@ export interface CreateFlowState {
string,
ConflictManagementDetailEntry
>;
/**
* Labels for user-authored method cards (UUID ids) added via the custom-method-card wizard.
* Preset rows resolve from messages JSON; these entries supply title/support for publish + final-review.
*/
customMethodCardMetaById?: Record<
string,
{ label: string; supportText: string }
>;
/**
* Custom data-field templates authored in the custom-method-card wizard (step 3).
* Keyed by the same UUID as `customMethodCardMetaById` for that card.
*/
customMethodCardFieldBlocksById?: Record<string, CustomMethodCardFieldBlock[]>;
/**
* Set when a user picks a template (Customize or Use without changes) before
* completing the community stage. The community-review screen consumes this