Add custom intervention modals
This commit is contained in:
@@ -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);
|
||||
+358
@@ -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";
|
||||
+161
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
import Image from "next/image";
|
||||
import { memo } from "react";
|
||||
import ArrowBackIcon from "./arrow_back.svg";
|
||||
import ChevronRightIcon from "./chevron_right.svg";
|
||||
import ContentCopyIcon from "./content_copy.svg";
|
||||
import CsvIcon from "./csv.svg";
|
||||
import EditIcon from "./edit.svg";
|
||||
import ExclamationIcon from "./exclamation.svg";
|
||||
import ChevronRightIcon from "./chevron_right.svg";
|
||||
import CsvIcon from "./csv.svg";
|
||||
import ImageGlyphIcon from "./image.svg";
|
||||
import LogOutIcon from "./log_out.svg";
|
||||
import MailIcon from "./mail.svg";
|
||||
import MarkdownCopyIcon from "./markdown_copy.svg";
|
||||
import NumberIcon from "./number.svg";
|
||||
import PictureAsPdfIcon from "./picture_as_pdf.svg";
|
||||
import TagsIcon from "./tags.svg";
|
||||
import TextBlockIcon from "./text_block.svg";
|
||||
import WarningIcon from "./warning.svg";
|
||||
|
||||
export const ICON_NAME_OPTIONS = [
|
||||
@@ -21,10 +25,14 @@ export const ICON_NAME_OPTIONS = [
|
||||
"csv",
|
||||
"edit",
|
||||
"exclamation",
|
||||
"image",
|
||||
"log_out",
|
||||
"mail",
|
||||
"markdown_copy",
|
||||
"number",
|
||||
"picture_as_pdf",
|
||||
"tags",
|
||||
"text_block",
|
||||
"warning",
|
||||
] as const;
|
||||
|
||||
@@ -42,10 +50,14 @@ const iconMap: Record<IconName, SvgComponent> = {
|
||||
csv: CsvIcon,
|
||||
edit: EditIcon,
|
||||
exclamation: ExclamationIcon,
|
||||
image: ImageGlyphIcon,
|
||||
log_out: LogOutIcon,
|
||||
mail: MailIcon,
|
||||
markdown_copy: MarkdownCopyIcon,
|
||||
number: NumberIcon,
|
||||
picture_as_pdf: PictureAsPdfIcon,
|
||||
tags: TagsIcon,
|
||||
text_block: TextBlockIcon,
|
||||
warning: WarningIcon,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_19787_11971)">
|
||||
<path d="M5 3.75H19C19.6858 3.75 20.25 4.31421 20.25 5V19C20.25 19.6858 19.6858 20.25 19 20.25H5C4.31421 20.25 3.75 19.6858 3.75 19V5C3.75 4.31421 4.31421 3.75 5 3.75Z" stroke="white" stroke-width="1.5"/>
|
||||
<path d="M10.7496 15.298L9.39281 13.7098C9.18897 13.4712 8.81826 13.4772 8.62221 13.7222L6.64988 16.1877C6.38797 16.515 6.62106 17 7.04031 17H16.9828C17.398 17 17.6323 16.5233 17.3787 16.1946L14.532 12.5045C14.334 12.2477 13.9477 12.2445 13.7454 12.498L11.5205 15.2852C11.3246 15.5306 10.9536 15.5368 10.7496 15.298Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_19787_11971">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 791 B |
@@ -0,0 +1,10 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_19787_11962)">
|
||||
<path d="M20.5 9.75L21 7.75H17L18 3.75H16L15 7.75H11L12 3.75H10L9 7.75H5L4.5 9.75H8.5L7.5 13.75H3.5L3 15.75H7L6 19.75H8L9 15.75H13L12 19.75H14L15 15.75H19L19.5 13.75H15.5L16.5 9.75H20.5ZM13.5 13.75H9.5L10.5 9.75H14.5L13.5 13.75Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_19787_11962">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 498 B |
@@ -0,0 +1,7 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.51299 10.462C7.32099 10.654 7.08332 10.75 6.79999 10.75C6.51665 10.75 6.27899 10.654 6.08699 10.462C5.89565 10.2707 5.79999 10.0333 5.79999 9.75C5.79999 9.46667 5.89565 9.229 6.08699 9.037C6.27899 8.84567 6.51665 8.75 6.79999 8.75C7.08332 8.75 7.32099 8.84567 7.51299 9.037C7.70432 9.229 7.79999 9.46667 7.79999 9.75C7.79999 10.0333 7.70432 10.2707 7.51299 10.462Z" fill="white"/>
|
||||
<path d="M12.8 14.75H6.79999C6.51665 14.75 6.27899 14.654 6.08699 14.462C5.89565 14.2707 5.79999 14.0333 5.79999 13.75C5.79999 13.4667 5.89565 13.229 6.08699 13.037C6.27899 12.8457 6.51665 12.75 6.79999 12.75H12.8C13.0833 12.75 13.321 12.8457 13.513 13.037C13.7043 13.229 13.8 13.4667 13.8 13.75C13.8 14.0333 13.7043 14.2707 13.513 14.462C13.321 14.654 13.0833 14.75 12.8 14.75Z" fill="white"/>
|
||||
<path d="M17.512 14.462C17.3207 14.654 17.0833 14.75 16.8 14.75C16.5167 14.75 16.2793 14.654 16.088 14.462C15.896 14.2707 15.8 14.0333 15.8 13.75C15.8 13.4667 15.896 13.229 16.088 13.037C16.2793 12.8457 16.5167 12.75 16.8 12.75C17.0833 12.75 17.3207 12.8457 17.512 13.037C17.704 13.229 17.8 13.4667 17.8 13.75C17.8 14.0333 17.704 14.2707 17.512 14.462Z" fill="white"/>
|
||||
<path d="M16.8 10.75H10.8C10.5167 10.75 10.2793 10.654 10.088 10.462C9.89599 10.2707 9.79999 10.0333 9.79999 9.75C9.79999 9.46667 9.89599 9.229 10.088 9.037C10.2793 8.84567 10.5167 8.75 10.8 8.75H16.8C17.0833 8.75 17.3207 8.84567 17.512 9.037C17.704 9.229 17.8 9.46667 17.8 9.75C17.8 10.0333 17.704 10.2707 17.512 10.462C17.3207 10.654 17.0833 10.75 16.8 10.75Z" fill="white"/>
|
||||
<path d="M3.875 4.5H19.875C20.2243 4.5 20.5052 4.61557 20.7578 4.86816C21.0095 5.11983 21.125 5.4003 21.125 5.75V17.75C21.125 18.0993 21.0092 18.3796 20.7578 18.6318C20.5053 18.8839 20.2247 19 19.875 19H3.875C3.5253 19 3.24483 18.8845 2.99316 18.6328C2.74057 18.3802 2.625 18.0993 2.625 17.75V5.75C2.625 5.40073 2.74099 5.1209 2.99316 4.86914L2.99414 4.86816C3.2459 4.61599 3.52573 4.5 3.875 4.5Z" stroke="white" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 20C3.45 20 2.97917 19.8042 2.5875 19.4125C2.19583 19.0208 2 18.55 2 18V6C2 5.45 2.19583 4.97917 2.5875 4.5875C2.97917 4.19583 3.45 4 4 4H20C20.55 4 21.0208 4.19583 21.4125 4.5875C21.8042 4.97917 22 5.45 22 6V18C22 18.55 21.8042 19.0208 21.4125 19.4125C21.0208 19.8042 20.55 20 20 20H4Z" stroke="white" stroke-width="1.5"/>
|
||||
<path d="M18 17H6C5.71667 17 5.47917 16.9042 5.2875 16.7125C5.09583 16.5208 5 16.2833 5 16C5 15.7167 5.09583 15.4792 5.2875 15.2875C5.47917 15.0958 5.71667 15 6 15H18C18.2833 15 18.5208 15.0958 18.7125 15.2875C18.9042 15.4792 19 15.7167 19 16C19 16.2833 18.9042 16.5208 18.7125 16.7125C18.5208 16.9042 18.2833 17 18 17Z" fill="white"/>
|
||||
<path d="M18 13H6C5.71667 13 5.47917 12.9042 5.2875 12.7125C5.09583 12.5208 5 12.2833 5 12C5 11.7167 5.09583 11.4792 5.2875 11.2875C5.47917 11.0958 5.71667 11 6 11H18C18.2833 11 18.5208 11.0958 18.7125 11.2875C18.9042 11.4792 19 11.7167 19 12C19 12.2833 18.9042 12.5208 18.7125 12.7125C18.5208 12.9042 18.2833 13 18 13Z" fill="white"/>
|
||||
<path d="M14 9H6C5.71667 9 5.47917 8.90417 5.2875 8.7125C5.09583 8.52083 5 8.28333 5 8C5 7.71667 5.09583 7.47917 5.2875 7.2875C5.47917 7.09583 5.71667 7 6 7H14C14.2833 7 14.5208 7.09583 14.7125 7.2875C14.9042 7.47917 15 7.71667 15 8C15 8.28333 14.9042 8.52083 14.7125 8.7125C14.5208 8.90417 14.2833 9 14 9Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
/**
|
||||
* Figma: Community Rule System — **Vertical button** (`19787:10896`).
|
||||
*
|
||||
* Tile control: column layout, brand-primary border on transparent surface,
|
||||
* 32px icon slot + centered 14/18 medium label (label rendered by `children`).
|
||||
*/
|
||||
export interface VerticalProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: (_event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
function VerticalComponent({
|
||||
children,
|
||||
onClick,
|
||||
className = "",
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
type = "button",
|
||||
"data-testid": dataTestId,
|
||||
}: VerticalProps) {
|
||||
const base =
|
||||
"box-border flex w-[90px] shrink-0 cursor-pointer flex-col items-center gap-[var(--spacing-scale-008)] rounded-[var(--spacing-scale-004)] border border-solid border-[var(--color-border-default-brand-primary)] bg-transparent px-[var(--spacing-scale-008)] py-[var(--spacing-scale-012)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] disabled:cursor-not-allowed disabled:opacity-60";
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
data-testid={dataTestId}
|
||||
className={`${base} ${className}`.trim()}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
VerticalComponent.displayName = "Vertical";
|
||||
|
||||
export default memo(VerticalComponent);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Vertical";
|
||||
export type { VerticalProps } from "./Vertical";
|
||||
@@ -32,6 +32,10 @@ const CardStackContainer = memo<CardStackProps>(
|
||||
headerLockupSize,
|
||||
toggleAlignment = "center",
|
||||
className = "",
|
||||
showAddCard = false,
|
||||
addCardLabel = "",
|
||||
addCardAriaLabel = "",
|
||||
onAddCard,
|
||||
}) => {
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(
|
||||
@@ -90,6 +94,10 @@ const CardStackContainer = memo<CardStackProps>(
|
||||
headerLockupSize={headerLockupSize}
|
||||
toggleAlignment={toggleAlignment}
|
||||
className={className}
|
||||
showAddCard={showAddCard}
|
||||
addCardLabel={addCardLabel}
|
||||
addCardAriaLabel={addCardAriaLabel}
|
||||
onAddCard={onAddCard}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup.types";
|
||||
|
||||
export interface CardStackItem {
|
||||
@@ -18,7 +19,7 @@ export interface CardStackProps {
|
||||
toggleLabel?: string;
|
||||
showLessLabel?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
description?: ReactNode;
|
||||
/** "default" = compact grid/column + expanded grid; "singleStack" = always one column, expand shows more in same stack */
|
||||
layout?: "default" | "singleStack";
|
||||
/**
|
||||
@@ -45,6 +46,11 @@ export interface CardStackProps {
|
||||
/** Alignment of the expand/collapse control in `singleStack` layout (Figma right-rail: end). */
|
||||
toggleAlignment?: "center" | "end";
|
||||
className?: string;
|
||||
/** Optional “Add” entry (e.g. custom method card wizard). */
|
||||
showAddCard?: boolean;
|
||||
addCardLabel?: string;
|
||||
addCardAriaLabel?: string;
|
||||
onAddCard?: () => void;
|
||||
}
|
||||
|
||||
export interface CardStackViewProps {
|
||||
@@ -57,7 +63,7 @@ export interface CardStackViewProps {
|
||||
toggleLabel: string;
|
||||
showLessLabel: string;
|
||||
title: string;
|
||||
description: string;
|
||||
description: ReactNode;
|
||||
layout: "default" | "singleStack";
|
||||
compactRecommendedLimit: number;
|
||||
compactCardIds: string[] | undefined;
|
||||
@@ -65,4 +71,8 @@ export interface CardStackViewProps {
|
||||
headerLockupSize: HeaderLockupSizeValue | undefined;
|
||||
toggleAlignment: "center" | "end";
|
||||
className: string;
|
||||
showAddCard: boolean;
|
||||
addCardLabel: string;
|
||||
addCardAriaLabel: string;
|
||||
onAddCard?: () => void;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import HeaderLockup from "../../type/HeaderLockup";
|
||||
import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup.types";
|
||||
import Selection from "../Selection";
|
||||
import type { CardStackViewProps } from "./CardStack.types";
|
||||
|
||||
function CardStackHeaderLockup({
|
||||
title,
|
||||
description,
|
||||
justification,
|
||||
size,
|
||||
wrapperClassName,
|
||||
}: {
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
justification: "center" | "left";
|
||||
size: HeaderLockupSizeValue;
|
||||
wrapperClassName?: string;
|
||||
}) {
|
||||
if (!title && !description) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={wrapperClassName ?? "min-w-0"}>
|
||||
<HeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification={justification}
|
||||
size={size}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardStackAddCardButton({
|
||||
label,
|
||||
ariaLabel,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
className="flex min-h-[88px] w-full shrink-0 items-center justify-center rounded-[var(--measures-radius-medium,8px)] bg-[var(--color-surface-default-secondary)] font-inter text-base font-medium text-[var(--color-content-default-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)]"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardStackView({
|
||||
cards,
|
||||
selectedIds,
|
||||
@@ -22,7 +90,19 @@ export function CardStackView({
|
||||
headerLockupSize,
|
||||
toggleAlignment,
|
||||
className,
|
||||
showAddCard,
|
||||
addCardLabel,
|
||||
addCardAriaLabel,
|
||||
onAddCard,
|
||||
}: CardStackViewProps) {
|
||||
const addTile =
|
||||
showAddCard && onAddCard && addCardLabel.length > 0 ? (
|
||||
<CardStackAddCardButton
|
||||
label={addCardLabel}
|
||||
ariaLabel={addCardAriaLabel || addCardLabel}
|
||||
onClick={onAddCard}
|
||||
/>
|
||||
) : null;
|
||||
const lockupSize = headerLockupSize ?? "L";
|
||||
const isSelected = (id: string) => selectedIds.includes(id);
|
||||
// Compact: explicit `compactCardIds` (caller-driven, used by create-flow
|
||||
@@ -47,16 +127,13 @@ export function CardStackView({
|
||||
const displayedCards = expanded ? cards : compactCards;
|
||||
return (
|
||||
<div className={`flex w-full flex-col gap-6 min-w-0 ${className}`}>
|
||||
{title || description ? (
|
||||
<div className="min-w-0 shrink-0">
|
||||
<HeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size={lockupSize}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<CardStackHeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size={lockupSize}
|
||||
wrapperClassName="min-w-0 shrink-0"
|
||||
/>
|
||||
<div className="flex w-full min-w-0 flex-col gap-2">
|
||||
{displayedCards.map((item) => (
|
||||
<Selection
|
||||
@@ -71,6 +148,7 @@ export function CardStackView({
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
{addTile}
|
||||
</div>
|
||||
{hasMore ? (
|
||||
<button
|
||||
@@ -89,16 +167,12 @@ export function CardStackView({
|
||||
|
||||
return (
|
||||
<div className={`flex w-full flex-col gap-6 min-w-0 ${className}`}>
|
||||
{title || description ? (
|
||||
<div className="min-w-0">
|
||||
<HeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size={lockupSize}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<CardStackHeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size={lockupSize}
|
||||
/>
|
||||
|
||||
{expanded ? (
|
||||
<div className="mx-auto grid w-full max-w-[min(100%,860px)] grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
|
||||
@@ -115,6 +189,9 @@ export function CardStackView({
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
{addTile ? (
|
||||
<div className="min-w-0 md:col-span-2">{addTile}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : compactDesktopLayout === "pyramidFive" ? (
|
||||
<>
|
||||
@@ -133,6 +210,7 @@ export function CardStackView({
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
{addTile}
|
||||
</div>
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] md:block">
|
||||
{/*
|
||||
@@ -228,6 +306,11 @@ export function CardStackView({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{addTile ? (
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] md:block">
|
||||
{addTile}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : compactDesktopLayout === "flexWrap" ? (
|
||||
<>
|
||||
@@ -246,6 +329,7 @@ export function CardStackView({
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
{addTile}
|
||||
</div>
|
||||
{/* md–lg: pyramid (2 + 1), each row centered; lg+: one centered row (not edge-to-edge in a 2-col grid) */}
|
||||
{compactCards.length === 3 ? (
|
||||
@@ -280,6 +364,9 @@ export function CardStackView({
|
||||
onClick={() => onCardSelect(compactCards[2].id)}
|
||||
/>
|
||||
</div>
|
||||
{addTile ? (
|
||||
<div className="flex w-full justify-center px-2">{addTile}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-wrap justify-center gap-2 lg:flex">
|
||||
{compactCards.map((item) => (
|
||||
@@ -297,6 +384,11 @@ export function CardStackView({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{addTile ? (
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] lg:flex lg:justify-center">
|
||||
<div className="w-full min-w-[281px] max-w-[574px]">{addTile}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-wrap justify-center gap-2 md:flex">
|
||||
@@ -318,6 +410,11 @@ export function CardStackView({
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{addTile ? (
|
||||
<div className="flex w-full min-w-0 shrink-0 justify-center md:w-[281px] md:max-w-[574px] md:flex-[1_1_100%]">
|
||||
{addTile}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -338,6 +435,7 @@ export function CardStackView({
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
{addTile}
|
||||
</div>
|
||||
{/* Compact 640+: 6-col grid so each card spans 2; second row centered (cols 2–3 and 4–5) */}
|
||||
<div className="hidden md:grid grid-cols-6 gap-x-4 gap-y-6 w-full">
|
||||
@@ -365,6 +463,9 @@ export function CardStackView({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{addTile ? (
|
||||
<div className="col-span-6 min-w-0">{addTile}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { AddCustomFieldView } from "./AddCustomField.view";
|
||||
import type { AddCustomFieldProps, AddCustomFieldType } from "./AddCustomField.types";
|
||||
|
||||
/**
|
||||
* Figma: "Add Custom Field" control — Community Rule System (`20235:12994`).
|
||||
* Collapsed CTA expands to a 2×2 field-type picker (per-type modals deferred).
|
||||
*/
|
||||
const AddCustomFieldContainer = memo<AddCustomFieldProps>(
|
||||
({ active, onPressAdd, onSelectFieldType, className = "" }) => {
|
||||
const m = useMessages();
|
||||
const copy = m.create.customRule.customMethodCardWizard.addCustomField;
|
||||
|
||||
const fieldTypeLabels = useMemo(
|
||||
() => ({
|
||||
text: copy.fieldTypes.text,
|
||||
badges: copy.fieldTypes.badges,
|
||||
upload: copy.fieldTypes.upload,
|
||||
proportion: copy.fieldTypes.proportion,
|
||||
}),
|
||||
[copy.fieldTypes],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(t: AddCustomFieldType) => {
|
||||
onSelectFieldType?.(t);
|
||||
},
|
||||
[onSelectFieldType],
|
||||
);
|
||||
|
||||
return (
|
||||
<AddCustomFieldView
|
||||
active={active}
|
||||
onPressAdd={onPressAdd}
|
||||
onSelectFieldType={handleSelect}
|
||||
ctaLabel={copy.cta}
|
||||
fieldTypeLabels={fieldTypeLabels}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AddCustomFieldContainer.displayName = "AddCustomField";
|
||||
|
||||
export default AddCustomFieldContainer;
|
||||
@@ -0,0 +1,18 @@
|
||||
export type AddCustomFieldType = "text" | "badges" | "upload" | "proportion";
|
||||
|
||||
export interface AddCustomFieldProps {
|
||||
/** When true, show the 2×2 field-type grid; when false, show the primary CTA. */
|
||||
active: boolean;
|
||||
onPressAdd?: () => void;
|
||||
onSelectFieldType?: (type: AddCustomFieldType) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface AddCustomFieldViewProps {
|
||||
active: boolean;
|
||||
onPressAdd?: () => void;
|
||||
onSelectFieldType?: (type: AddCustomFieldType) => void;
|
||||
ctaLabel: string;
|
||||
fieldTypeLabels: Record<AddCustomFieldType, string>;
|
||||
className: string;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Icon, { type IconName } from "../../asset/icon";
|
||||
import Vertical from "../../buttons/Vertical";
|
||||
import type {
|
||||
AddCustomFieldType,
|
||||
AddCustomFieldViewProps,
|
||||
} from "./AddCustomField.types";
|
||||
|
||||
const FIELD_TYPE_ICONS: Record<AddCustomFieldType, IconName> = {
|
||||
text: "text_block",
|
||||
badges: "tags", // tag / chip list (filename: tags.svg)
|
||||
upload: "image", // image / file upload (filename: image.svg)
|
||||
proportion: "number", // numeric / proportion field (closest asset: number.svg)
|
||||
};
|
||||
|
||||
function FieldTypeButton({
|
||||
type,
|
||||
label,
|
||||
onSelect,
|
||||
}: {
|
||||
type: AddCustomFieldType;
|
||||
label: string;
|
||||
onSelect?: (t: AddCustomFieldType) => void;
|
||||
}) {
|
||||
return (
|
||||
<Vertical
|
||||
type="button"
|
||||
ariaLabel={label}
|
||||
onClick={() => onSelect?.(type)}
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
<Icon
|
||||
name={FIELD_TYPE_ICONS[type]}
|
||||
size={32}
|
||||
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
|
||||
/>
|
||||
</span>
|
||||
<span className="w-full text-center font-inter text-[14px] font-medium leading-[18px] text-[var(--color-content-default-brand-primary,#fefcc9)]">
|
||||
{label}
|
||||
</span>
|
||||
</Vertical>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable block height for collapsed vs expanded so the Create dialog (`top-1/2 -translate-y-1/2`)
|
||||
* does not shrink and re-center when toggling `active`.
|
||||
*
|
||||
* - Collapsed CTA: `py-12` (48+48) + inner row (`py-3` + 20px icon/line) ≈ 140px border-box.
|
||||
* - Expanded: inner `p-4` (32) + Vertical tile (py 12+12, gap 8, 32px icon, 18px label) ≈ 114px — shorter without this floor.
|
||||
*/
|
||||
const ADD_CUSTOM_FIELD_SHELL_MIN_H_PX = 140;
|
||||
|
||||
function AddCustomFieldViewComponent({
|
||||
active,
|
||||
onPressAdd,
|
||||
onSelectFieldType,
|
||||
ctaLabel,
|
||||
fieldTypeLabels,
|
||||
className,
|
||||
}: AddCustomFieldViewProps) {
|
||||
const shellStyle = {
|
||||
minHeight: ADD_CUSTOM_FIELD_SHELL_MIN_H_PX,
|
||||
} as const;
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPressAdd}
|
||||
style={shellStyle}
|
||||
className={`flex w-full shrink-0 cursor-pointer items-center justify-center rounded-[var(--measures-radius-medium,8px)] bg-[var(--color-surface-default-secondary)] px-6 py-12 font-inter text-[16px] font-medium leading-5 text-[var(--color-content-default-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] ${className ?? ""}`.trim()}
|
||||
>
|
||||
<span className="flex items-center gap-[var(--spacing-scale-006)] rounded-full px-4 py-3">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{ctaLabel}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const expandedShellClasses = ["flex w-full shrink-0 flex-col", className ?? ""]
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
return (
|
||||
<div className={expandedShellClasses} style={shellStyle}>
|
||||
<div className="flex w-full flex-col gap-3 rounded-[var(--measures-radius-medium,8px)] bg-[var(--color-surface-default-secondary)] p-4">
|
||||
<div className="flex w-full flex-row flex-nowrap justify-center gap-3 overflow-x-auto max-sm:justify-start">
|
||||
<FieldTypeButton
|
||||
type="text"
|
||||
label={fieldTypeLabels.text}
|
||||
onSelect={onSelectFieldType}
|
||||
/>
|
||||
<FieldTypeButton
|
||||
type="badges"
|
||||
label={fieldTypeLabels.badges}
|
||||
onSelect={onSelectFieldType}
|
||||
/>
|
||||
<FieldTypeButton
|
||||
type="upload"
|
||||
label={fieldTypeLabels.upload}
|
||||
onSelect={onSelectFieldType}
|
||||
/>
|
||||
<FieldTypeButton
|
||||
type="proportion"
|
||||
label={fieldTypeLabels.proportion}
|
||||
onSelect={onSelectFieldType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const AddCustomFieldView = memo(AddCustomFieldViewComponent);
|
||||
AddCustomFieldView.displayName = "AddCustomFieldView";
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default } from "./AddCustomField.container";
|
||||
export type {
|
||||
AddCustomFieldProps,
|
||||
AddCustomFieldType,
|
||||
} from "./AddCustomField.types";
|
||||
@@ -27,6 +27,7 @@ const CreateContainer = memo<CreateProps>(
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
backdropVariant = "default",
|
||||
stepper,
|
||||
}) => {
|
||||
const createRef = useRef<HTMLDivElement>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
@@ -58,6 +59,7 @@ const CreateContainer = memo<CreateProps>(
|
||||
createRef={createRef}
|
||||
overlayRef={overlayRef}
|
||||
backdropVariant={backdropVariant}
|
||||
stepper={stepper}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface CreateProps {
|
||||
* @default "default"
|
||||
*/
|
||||
backdropVariant?: CreateModalBackdropVariant;
|
||||
/** Passed through to ModalFooter; set explicitly when step visibility must not infer from steps alone. */
|
||||
stepper?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateViewProps {
|
||||
@@ -60,4 +62,5 @@ export interface CreateViewProps {
|
||||
createRef: RefObject<HTMLDivElement | null>;
|
||||
overlayRef: RefObject<HTMLDivElement | null>;
|
||||
backdropVariant: CreateModalBackdropVariant;
|
||||
stepper?: boolean;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export function CreateView({
|
||||
createRef,
|
||||
overlayRef,
|
||||
backdropVariant,
|
||||
stepper,
|
||||
}: CreateViewProps) {
|
||||
return (
|
||||
<CreateModalFrameView
|
||||
@@ -70,6 +71,7 @@ export function CreateView({
|
||||
nextButtonDisabled={nextButtonDisabled}
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
stepper={stepper}
|
||||
footerContent={footerContent}
|
||||
/>
|
||||
</CreateModalFrameView>
|
||||
|
||||
Reference in New Issue
Block a user