Add custom intervention modals

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

+10
View File
@@ -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

+7
View File
@@ -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

+6
View File
@@ -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;
}
+121 -20
View File
@@ -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>
{/* mdlg: 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 23 and 45) */}
<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>
+15 -1
View File
@@ -30,10 +30,24 @@ export function applyFinalReviewChipEditPatch(
current && typeof current === "object"
? (current as Record<string, unknown>)
: {};
return {
const detailPatch: Partial<CreateFlowState> = {
[stateKey]: {
...record,
[patch.overrideKey]: patch.value,
},
};
if (
patch.groupKey !== "coreValues" &&
"customMethodCardFieldBlocks" in patch &&
patch.customMethodCardFieldBlocks !== undefined
) {
return {
...detailPatch,
customMethodCardFieldBlocksById: {
...(state.customMethodCardFieldBlocksById ?? {}),
[patch.overrideKey]: patch.customMethodCardFieldBlocks,
},
};
}
return detailPatch;
}
+14 -2
View File
@@ -62,14 +62,22 @@ function entriesFromIds(
ids: readonly string[] | undefined,
methods: readonly MethodPreset[],
groupKey: TemplateFacetGroupKey,
customMeta?: CreateFlowState["customMethodCardMetaById"],
): FinalReviewChipEntry[] {
if (!ids || ids.length === 0) return [];
const byId = new Map(methods.map((m) => [m.id, m.label] as const));
const seen = new Set<string>();
const out: FinalReviewChipEntry[] = [];
for (const id of ids) {
const label = byId.get(id);
if (typeof label !== "string" || label.length === 0) continue;
const presetLabel = byId.get(id);
const fromCustom = customMeta?.[id]?.label?.trim();
const label =
typeof presetLabel === "string" && presetLabel.length > 0
? presetLabel
: typeof fromCustom === "string" && fromCustom.length > 0
? fromCustom
: "";
if (label.length === 0) continue;
if (seen.has(label)) continue;
seen.add(label);
out.push({ label, groupKey, overrideKey: id });
@@ -181,6 +189,7 @@ export function buildFinalReviewCategoryRowsDetailed(
state.selectedCommunicationMethodIds,
methodsForGroup("communication"),
"communication",
state.customMethodCardMetaById,
),
},
{
@@ -190,6 +199,7 @@ export function buildFinalReviewCategoryRowsDetailed(
state.selectedMembershipMethodIds,
methodsForGroup("membership"),
"membership",
state.customMethodCardMetaById,
),
},
{
@@ -199,6 +209,7 @@ export function buildFinalReviewCategoryRowsDetailed(
state.selectedDecisionApproachIds,
methodsForGroup("decisionApproaches"),
"decisionApproaches",
state.customMethodCardMetaById,
),
},
{
@@ -208,6 +219,7 @@ export function buildFinalReviewCategoryRowsDetailed(
state.selectedConflictManagementIds,
methodsForGroup("conflictManagement"),
"conflictManagement",
state.customMethodCardMetaById,
),
},
];
+21 -5
View File
@@ -13,7 +13,7 @@ import {
decisionApproachPresetFor,
membershipPresetFor,
mergeCoreValueDetailWithPresets,
methodLabelFor,
publishedMethodDisplayLabel,
} from "./finalReviewChipPresets";
import { isDocumentEntry } from "./documentEntryGuards";
import { replaceMethodSectionsWithMethodSelections } from "./ruleSectionsFromMethodSelections";
@@ -254,7 +254,11 @@ export function buildMethodSelectionsForDocument(
const override = state.communicationMethodDetailsById?.[id];
return {
id,
label: methodLabelFor("communication", id),
label: publishedMethodDisplayLabel(
"communication",
id,
state.customMethodCardMetaById,
),
sections: override ? { ...preset, ...override } : preset,
};
});
@@ -270,7 +274,11 @@ export function buildMethodSelectionsForDocument(
const override = state.membershipMethodDetailsById?.[id];
return {
id,
label: methodLabelFor("membership", id),
label: publishedMethodDisplayLabel(
"membership",
id,
state.customMethodCardMetaById,
),
sections: override ? { ...preset, ...override } : preset,
};
});
@@ -286,7 +294,11 @@ export function buildMethodSelectionsForDocument(
const override = state.decisionApproachDetailsById?.[id];
return {
id,
label: methodLabelFor("decisionApproaches", id),
label: publishedMethodDisplayLabel(
"decisionApproaches",
id,
state.customMethodCardMetaById,
),
sections: override ? { ...preset, ...override } : preset,
};
});
@@ -302,7 +314,11 @@ export function buildMethodSelectionsForDocument(
const override = state.conflictManagementDetailsById?.[id];
return {
id,
label: methodLabelFor("conflictManagement", id),
label: publishedMethodDisplayLabel(
"conflictManagement",
id,
state.customMethodCardMetaById,
),
sections: override ? { ...preset, ...override } : preset,
};
});
+75
View File
@@ -0,0 +1,75 @@
import { z } from "zod";
/** Serializable custom field blocks for a user-authored method card (wizard step 3). */
export type CustomMethodCardFieldBlock =
| {
kind: "text";
id: string;
blockTitle: string;
placeholderText: string;
}
| {
kind: "badges";
id: string;
blockTitle: string;
options: string[];
}
| {
kind: "upload";
id: string;
blockTitle: string;
fileName?: string;
}
| {
kind: "proportion";
id: string;
blockTitle: string;
defaultPercent: number;
};
const customMethodTextBlockSchema = z
.object({
kind: z.literal("text"),
id: z.string().max(80),
blockTitle: z.string().max(200),
placeholderText: z.string().max(8000),
})
.strict();
const customMethodBadgesBlockSchema = z
.object({
kind: z.literal("badges"),
id: z.string().max(80),
blockTitle: z.string().max(200),
options: z.array(z.string().max(200)).max(50),
})
.strict();
const customMethodUploadBlockSchema = z
.object({
kind: z.literal("upload"),
id: z.string().max(80),
blockTitle: z.string().max(200),
fileName: z.string().max(500).optional(),
})
.strict();
const customMethodProportionBlockSchema = z
.object({
kind: z.literal("proportion"),
id: z.string().max(80),
blockTitle: z.string().max(200),
defaultPercent: z.number().int().min(1).max(100),
})
.strict();
export const customMethodCardFieldBlockSchema = z.discriminatedUnion("kind", [
customMethodTextBlockSchema,
customMethodBadgesBlockSchema,
customMethodUploadBlockSchema,
customMethodProportionBlockSchema,
]);
export const customMethodCardFieldBlocksByIdSchema = z
.record(z.string().max(80), z.array(customMethodCardFieldBlockSchema).max(30))
.optional();
@@ -0,0 +1,2 @@
/** Max length for title and description fields in the add-custom-method-card wizard (Figma 0/48). */
export const CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS = 48;
+12
View File
@@ -8,6 +8,7 @@ import type {
CommunicationMethodDetailEntry,
ConflictManagementDetailEntry,
CoreValueDetailEntry,
CreateFlowState,
DecisionApproachDetailEntry,
MembershipMethodDetailEntry,
} from "../../app/(app)/create/types";
@@ -232,3 +233,14 @@ export function methodLabelFor(
const method = findMethod(source, id);
return method?.label ?? "";
}
/** Label for publish / review: preset JSON row, else user-authored wizard meta. */
export function publishedMethodDisplayLabel(
groupKey: TemplateFacetGroupKey,
id: string,
customMeta?: CreateFlowState["customMethodCardMetaById"],
): string {
const preset = methodLabelFor(groupKey, id);
if (preset.length > 0) return preset;
return customMeta?.[id]?.label?.trim() ?? "";
}
+13
View File
@@ -0,0 +1,13 @@
import type { CreateFlowState } from "../../app/(app)/create/types";
/**
* User-authored method cards (UUID ids) register a meta row when finalized
* from {@link CustomMethodCardWizard}. Preset rows from `methods[]` never
* appear here — keeps edit surfaces from treating custom ids like presets.
*/
export function isCustomMethodCardId(
methodId: string,
customMeta: CreateFlowState["customMethodCardMetaById"],
): boolean {
return Boolean(customMeta?.[methodId]);
}
@@ -0,0 +1,32 @@
/**
* Merge JSON preset method rows with user-created cards (stable UUID ids + meta).
* Custom rows follow `selectedIds` order (most-recent add at index 0 in create flow)
* but appear after all presets in this merged list; CardStack display order is
* layered separately via `useMethodCardDeckOrdering`.
*/
export function mergePresetMethodsWithCustom<
T extends { id: string; label: string; supportText?: string },
>(
presets: readonly T[],
selectedIds: readonly string[],
meta: Record<string, { label: string; supportText: string }> | undefined,
): T[] {
const presetIds = new Set(presets.map((p) => p.id));
const customRows: T[] = [];
const seenCustom = new Set<string>();
for (const id of selectedIds) {
if (presetIds.has(id)) continue;
const row = meta?.[id];
if (!row || seenCustom.has(id)) continue;
seenCustom.add(id);
customRows.push({
id,
label: row.label,
supportText: row.supportText,
} as T);
}
return [...presets, ...customRows];
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Canonical ordering for method-card facet `selected*Ids` when the user adds a card:
* most recently confirmed id is index 0 so stack / compact layouts stay consistent
* with {@link orderRankedMethodsWithPinnedSelection}.
*/
export function moveFacetSelectionIdToFront(
prev: readonly string[],
id: string,
): string[] {
const without = prev.filter((x) => x !== id);
return [id, ...without];
}
@@ -32,11 +32,10 @@ export function isPublishedRuleSelectionMissing(
}
/**
* Pin flags for method-card facets: compact CardStack slots surface selections
* first only when `methodSectionsPinCommitted[facet]` is true (see
* `useMethodCardDeckOrdering`). Normal wizard flow sets that on facet **Confirm**.
* Hydration paths that seed `selected*` method ids without a confirm (edit-published,
* template customize) merge this alongside those ids so pinning matches UX after Confirm.
* Pin flags for method-card facets: persisted for hydration and footer Confirm.
* Card Stack display pulls selections to the top whenever `selected*` ids are
* non-empty (`useMethodCardDeckOrdering`); this map still merges on edit /
* template paths so drafts mirror post-Confirm expectations.
*
* Caller should spread onto existing `methodSectionsPinCommitted` so unrelated facets stay
* as-is (`{ ...prior, ...this }`).
@@ -0,0 +1,86 @@
import type { CreateFlowState } from "../../app/(app)/create/types";
import {
CUSTOM_RULE_FACET_BY_GROUP,
type TemplateFacetGroupKey,
} from "./customRuleFacets";
import { isCustomMethodCardId } from "./isCustomMethodCardId";
export type MethodFacetGroupKey = Exclude<TemplateFacetGroupKey, "coreValues">;
/**
* Removes one method card id from a facets selection and clears its detail
* override row; if the id is a custom wizard card, also drops meta + field blocks.
*/
export function removeMethodCardFromFacetSelection(
state: CreateFlowState,
facetGroupKey: MethodFacetGroupKey,
cardId: string,
): Partial<CreateFlowState> {
const row = CUSTOM_RULE_FACET_BY_GROUP.get(facetGroupKey);
if (!row || row.kind !== "method") {
throw new Error(
`removeMethodCardFromFacetSelection: not a method facet (${facetGroupKey})`,
);
}
const selectedIds = [...row.selectionIds(state)];
if (!selectedIds.includes(cardId)) {
return {};
}
const nextSelected = selectedIds.filter((id) => id !== cardId);
const detailKey = row.detailOverridesStateKey as
| "communicationMethodDetailsById"
| "membershipMethodDetailsById"
| "decisionApproachDetailsById"
| "conflictManagementDetailsById";
const prevDetails = state[detailKey] as Record<string, unknown> | undefined;
const nextDetails: Record<string, unknown> = { ...(prevDetails ?? {}) };
delete nextDetails[cardId];
const patch: Partial<CreateFlowState> = {};
(patch as Record<string, unknown>)[row.selectedIdsStateKey] = nextSelected;
const detailsPatch =
Object.keys(nextDetails).length > 0 ? nextDetails : undefined;
switch (detailKey) {
case "communicationMethodDetailsById":
patch.communicationMethodDetailsById =
detailsPatch as CreateFlowState["communicationMethodDetailsById"];
break;
case "membershipMethodDetailsById":
patch.membershipMethodDetailsById =
detailsPatch as CreateFlowState["membershipMethodDetailsById"];
break;
case "decisionApproachDetailsById":
patch.decisionApproachDetailsById =
detailsPatch as CreateFlowState["decisionApproachDetailsById"];
break;
case "conflictManagementDetailsById":
patch.conflictManagementDetailsById =
detailsPatch as CreateFlowState["conflictManagementDetailsById"];
break;
default: {
const _exhaustive: never = detailKey;
throw new Error(
`removeMethodCardFromFacetSelection: unknown detail key ${_exhaustive}`,
);
}
}
const meta = state.customMethodCardMetaById ?? {};
if (isCustomMethodCardId(cardId, meta)) {
const nextMeta = { ...meta };
delete nextMeta[cardId];
patch.customMethodCardMetaById =
Object.keys(nextMeta).length > 0 ? nextMeta : undefined;
const nextBlocks = { ...(state.customMethodCardFieldBlocksById ?? {}) };
delete nextBlocks[cardId];
patch.customMethodCardFieldBlocksById =
Object.keys(nextBlocks).length > 0 ? nextBlocks : undefined;
}
return patch;
}
@@ -15,5 +15,7 @@ export function stripCustomRuleSelectionFields(
for (const key of STRIP_CUSTOM_RULE_SELECTION_STATE_KEYS) {
delete (out as Record<string, unknown>)[key as string];
}
delete (out as Record<string, unknown>).customMethodCardMetaById;
delete (out as Record<string, unknown>).customMethodCardFieldBlocksById;
return out;
}
@@ -1,5 +1,6 @@
import { z } from "zod";
import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps";
import { customMethodCardFieldBlocksByIdSchema } from "../../../lib/create/customMethodCardFieldBlocks";
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
const flowStepTuple = FLOW_STEP_ORDER as unknown as [string, ...string[]];
@@ -58,6 +59,11 @@ const conflictManagementDetailEntrySchema = z.object({
restorationFallbacks: z.string().max(8000),
});
const customMethodCardMetaEntrySchema = z.object({
label: z.string().max(48),
supportText: z.string().max(48),
});
/**
* Published rule `document` column: arbitrary JSON object with safety bounds.
*/
@@ -112,6 +118,10 @@ export const createFlowStateSchema = z
conflictManagementDetailsById: z
.record(conflictManagementDetailEntrySchema)
.optional(),
customMethodCardMetaById: z
.record(z.string().max(80), customMethodCardMetaEntrySchema)
.optional(),
customMethodCardFieldBlocksById: customMethodCardFieldBlocksByIdSchema,
methodSectionsPinCommitted: z
.object({
communication: z.boolean().optional(),
@@ -4,9 +4,10 @@
"compactTitle": "How should this community communicate with each-other?",
"compactDescriptionBefore": "You can select multiple methods for different needs or ",
"compactDescriptionLinkLabel": "add",
"compactDescriptionAfter": " your own",
"compactDescriptionAfter": " your own.",
"expandedTitle": "What method should this community use to communicate with eachother?",
"expandedDescription": "You can select multiple methods for different needs or add your own",
"expandedDescriptionBefore": "You can select multiple methods for different needs or ",
"expandedDescriptionAfter": " your own",
"seeAllLink": "See all communication approaches"
},
"confirmModal": {
@@ -17,6 +18,9 @@
"addPlatform": {
"nextButtonText": "Add Platform"
},
"removePlatform": {
"nextButtonText": "Remove"
},
"sectionHeadings": {
"corePrinciple": "Core Principle & Scope",
"logisticsAdmin": "Logistics, Admin & Norms",
@@ -6,7 +6,8 @@
"compactDescriptionLinkLabel": "add",
"compactDescriptionAfter": " new approaches to the list",
"expandedTitle": "How should conflicts be managed in your group?",
"expandedDescription": "You can also combine or add new approaches to the list",
"expandedDescriptionBefore": "You can also combine or ",
"expandedDescriptionAfter": " new approaches to the list",
"seeAllLink": "See all conflict management approaches"
},
"confirmModal": {
@@ -17,6 +18,9 @@
"addApproach": {
"nextButtonText": "Add Approach"
},
"removeApproach": {
"nextButtonText": "Remove"
},
"sectionHeadings": {
"corePrinciple": "Core Principle",
"applicableScope": "Applicable Scope",
@@ -0,0 +1,79 @@
{
"_comment": "Shared 3-step add-custom-method-card wizard (Create modal) for communication, membership, decision approaches, conflict management.",
"cardStack": {
"addCardLabel": "Add"
},
"steps": {
"1": {
"title": "What do you call your group's new policy?",
"description": "This will be the title of the policy",
"fieldPlaceholder": "Policy name"
},
"2": {
"title": "How do you want to describe your new policy?",
"description": "This description will show up with the title to offer additional context about what the policy is how it should be applied.",
"fieldPlaceholder": "Policy description"
},
"3": {
"title": "Custom policy details",
"description": "Configure a custom data structure for this policy by adding fields for text, proportions, multi-select options, or file uploads. This creates a reusable template with placeholders that allows your group to standardize how policy definitions are edited in the future."
}
},
"footer": {
"finalize": "Finalize policy"
},
"editModal": {
"placeholderBody": "This policy uses the title and description you set when you created it. Extra section fields from preset templates are hidden here so you are not shown empty boxes that do not match what you configured.",
"readout": {
"emptyValue": "—",
"noFileChosen": "No file chosen yet"
},
"clearFileLabel": "Clear file"
},
"addCustomField": {
"cta": "Add",
"fieldTypes": {
"text": "Text",
"badges": "Badges",
"upload": "Upload",
"proportion": "Proportion"
}
},
"fieldModals": {
"addField": "Add field",
"requiredHint": "Required",
"text": {
"title": "Add text block",
"description": "Text blocks let people write out policies and guidelines in detail. You can choose a title for the block and prefill it with placeholder text that members of your group can change or adjust as needed.",
"blockTitleLabel": "Text Block Title",
"blockTitlePlaceholder": "Add your text block title",
"placeholderLabel": "Text Block Placeholder",
"placeholderFieldPlaceholder": "Add optional prefilled text"
},
"badges": {
"title": "Add badge block",
"description": "Badge blocks let people choose between a list of prefilled items and add their own",
"blockTitleLabel": "Badge Block Title",
"blockTitlePlaceholder": "Describe the badge block",
"optionsLabel": "Prefilled Badge Options",
"addOptionLabel": "Add badge option"
},
"upload": {
"title": "Add upload block",
"description": "Upload blocks allow users to add images, PDFs, and other files to the policy",
"blockTitleLabel": "Upload Block Title",
"blockTitlePlaceholder": "Add your upload block title",
"uploadFileInputAriaLabel": "Choose file for this upload block",
"uploadHint": "Add images, PDFs, and other files to the policy"
},
"proportion": {
"title": "Add proportion block",
"description": "Proportion blocks help users choose a percentage value between one and one hundred",
"blockTitleLabel": "Proportion Block Title",
"blockTitlePlaceholder": "Add your proportion block title",
"defaultLabel": "Default proportion",
"decrementAriaLabel": "Decrease default proportion",
"incrementAriaLabel": "Increase default proportion"
}
}
}
@@ -29,7 +29,9 @@
},
"cardStack": {
"toggleSeeAll": "See all decision approaches",
"toggleShowLess": "Show less"
"toggleShowLess": "Show less",
"expandedStackDescriptionBefore": "You can also ",
"expandedStackDescriptionAfter": " a custom decision approach."
},
"confirmModal": {
"title": "Confirm selection",
@@ -39,6 +41,9 @@
"addApproach": {
"nextButtonText": "Add Approach"
},
"removeApproach": {
"nextButtonText": "Remove"
},
"sectionHeadings": {
"corePrinciple": "Core Principle",
"applicableScope": "Applicable Scope",
@@ -4,9 +4,10 @@
"compactTitle": "How do new members join\nand get connected?",
"compactDescriptionBefore": "You can select multiple methods for different needs or ",
"compactDescriptionLinkLabel": "add",
"compactDescriptionAfter": " your own",
"compactDescriptionAfter": " your own.",
"expandedTitle": "How should new members join and get connected?",
"expandedDescription": "You can select multiple methods for different needs or add your own",
"expandedDescriptionBefore": "You can select multiple methods for different needs or ",
"expandedDescriptionAfter": " your own",
"seeAllLink": "See all membership approaches"
},
"confirmModal": {
@@ -17,6 +18,9 @@
"addPlatform": {
"nextButtonText": "Add Platform"
},
"removePlatform": {
"nextButtonText": "Remove"
},
"sectionHeadings": {
"eligibility": "Eligibility & Philosophy",
"joiningProcess": "Joining Process",
+2
View File
@@ -40,6 +40,7 @@ import createCommunication from "./create/customRule/communication.json";
import createMembership from "./create/customRule/membership.json";
import createDecisionApproaches from "./create/customRule/decisionApproaches.json";
import createConflictManagement from "./create/customRule/conflictManagement.json";
import createCustomMethodCardWizard from "./create/customRule/customMethodCardWizard.json";
// create stage 3: reviewAndComplete
import createConfirmStakeholders from "./create/reviewAndComplete/confirmStakeholders.json";
@@ -94,6 +95,7 @@ export default {
membership: createMembership,
decisionApproaches: createDecisionApproaches,
conflictManagement: createConflictManagement,
customMethodCardWizard: createCustomMethodCardWizard,
},
reviewAndComplete: {
confirmStakeholders: createConfirmStakeholders,
+65
View File
@@ -0,0 +1,65 @@
import React from "react";
import Icon from "../../app/components/asset/icon";
import Vertical from "../../app/components/buttons/Vertical";
/** Figma: Community Rule System — Vertical button (`19787:10896`). */
export default {
title: "Components/Buttons/Vertical",
component: Vertical,
parameters: {
layout: "centered",
docs: {
description: {
component:
"Tile-style control: vertical stack of icon and label with brand-primary border. Used for field-type pickers and similar compact grids.",
},
},
},
argTypes: {
disabled: { control: { type: "boolean" } },
ariaLabel: { control: { type: "text" } },
onClick: { action: "clicked" },
},
decorators: [
(Story) => (
<div className="w-[130px] bg-[var(--color-surface-default-primary)] p-4">
<Story />
</div>
),
],
tags: ["autodocs"],
};
export const Default = {
render: (args) => (
<Vertical {...args}>
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
<Icon
name="number"
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)]">
Number
</span>
</Vertical>
),
};
export const Disabled = {
render: (args) => (
<Vertical {...args} disabled ariaLabel="Number (disabled)">
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
<Icon
name="number"
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)]">
Number
</span>
</Vertical>
),
};
@@ -0,0 +1,40 @@
import React, { useState } from "react";
import AddCustomField from "../../app/components/controls/AddCustomField";
import { MessagesProvider } from "../../app/contexts/MessagesContext";
import messages from "../../messages/en/index";
/** Figma: Add Custom Field — node `20235:12994` (Community Rule System). */
export default {
title: "Components/Controls/AddCustomField",
component: AddCustomField,
decorators: [
(Story) => (
<MessagesProvider messages={messages}>
<div className="w-[min(100%,546px)] bg-[var(--color-surface-default-primary)] p-6">
<Story />
</div>
</MessagesProvider>
),
],
};
export const Collapsed = {
render: () => {
const [active, setActive] = useState(false);
return (
<AddCustomField
active={active}
onPressAdd={() => setActive(true)}
onSelectFieldType={() => {}}
/>
);
},
};
export const Expanded = {
args: {
active: true,
onPressAdd: () => {},
onSelectFieldType: () => {},
},
};
+118
View File
@@ -400,6 +400,124 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => {
expect(latest.communicationMethodDetailsById).toBeUndefined();
});
it("shows consolidated placeholder for user-authored communication chips", async () => {
const customId = "550e8400-e29b-41d4-a716-446655440000";
render(
<FinalReviewWithStateProbe
onState={() => {}}
initial={{
title: "Oak Park Commons",
selectedCommunicationMethodIds: [customId],
customMethodCardMetaById: {
[customId]: {
label: "Custom Comm",
supportText: "Support line from wizard",
},
},
}}
/>,
);
fireEvent.click(
await screen.findByRole("button", { name: "Custom Comm" }),
);
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText(/title and description you set/i),
).toBeInTheDocument();
expect(within(dialog).queryByRole("textbox")).toBeNull();
});
it("shows editable field blocks for user-authored communication chips when configured", async () => {
const customId = "550e8400-e29b-41d4-a716-446655440000";
render(
<FinalReviewWithStateProbe
onState={() => {}}
initial={{
title: "Oak Park Commons",
selectedCommunicationMethodIds: [customId],
customMethodCardMetaById: {
[customId]: {
label: "Custom Comm",
supportText: "Support line from wizard",
},
},
customMethodCardFieldBlocksById: {
[customId]: [
{
kind: "text",
id: "f1",
blockTitle: "Notes",
placeholderText: "Detail here",
},
],
},
}}
/>,
);
fireEvent.click(
await screen.findByRole("button", { name: "Custom Comm" }),
);
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).queryByText(/title and description you set/i),
).not.toBeInTheDocument();
const textarea = within(dialog).getByRole("textbox");
expect(textarea).not.toBeDisabled();
expect(textarea).toHaveValue("Detail here");
});
it("persists field block edits for user-authored communication chips on Save", async () => {
const customId = "550e8400-e29b-41d4-a716-446655440000";
let latest: CreateFlowState = {};
render(
<FinalReviewWithStateProbe
onState={(s) => {
latest = s;
}}
initial={{
title: "Oak Park Commons",
selectedCommunicationMethodIds: [customId],
customMethodCardMetaById: {
[customId]: {
label: "Custom Comm",
supportText: "Support line from wizard",
},
},
customMethodCardFieldBlocksById: {
[customId]: [
{
kind: "text",
id: "f1",
blockTitle: "Notes",
placeholderText: "Detail here",
},
],
},
}}
/>,
);
fireEvent.click(
await screen.findByRole("button", { name: "Custom Comm" }),
);
const dialog = await screen.findByRole("dialog");
const textarea = within(dialog).getByRole("textbox");
fireEvent.change(textarea, { target: { value: "Saved detail" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(
latest.customMethodCardFieldBlocksById?.[customId]?.[0],
).toMatchObject({
kind: "text",
placeholderText: "Saved detail",
});
});
});
function FinalReviewEditPublishedWithStateProbe({
+198 -1
View File
@@ -6,7 +6,19 @@ import {
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { describe, test, expect, afterEach } from "vitest";
import { useLayoutEffect } from "react";
import { CommunicationMethodsScreen } from "../../app/(app)/create/screens/card/CommunicationMethodsScreen";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
const CUSTOM_POLICY_ID = "550e8400-e29b-41d4-a716-446655440000";
function CommunicationMethodsScreenWithState({ initial }) {
const { replaceState } = useCreateFlow();
useLayoutEffect(() => {
replaceState(initial);
}, [replaceState, initial]);
return <CommunicationMethodsScreen />;
}
afterEach(() => {
cleanup();
@@ -28,6 +40,48 @@ describe("Create flow communication-methods page", () => {
expect(within(dialog).getByText("Add Platform")).toBeInTheDocument();
});
test("re-opening a selected method shows Remove as the modal primary action", async () => {
const user = userEvent.setup();
render(<CommunicationMethodsScreen />);
const signalCards = screen.getAllByRole("button", {
name: /Signal: Encrypted messaging/,
});
await user.click(signalCards[0]);
const dialog = screen.getByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: "Add Platform" }));
await user.click(signalCards[0]);
const dialogAgain = screen.getByRole("dialog");
expect(
within(dialogAgain).getByRole("button", { name: "Remove" }),
).toBeInTheDocument();
});
test("Remove in the modal deselects the method", async () => {
const user = userEvent.setup();
render(<CommunicationMethodsScreen />);
const signalCards = screen.getAllByRole("button", {
name: /Signal: Encrypted messaging/,
});
await user.click(signalCards[0]);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", {
name: "Add Platform",
}),
);
expect(signalCards[0]).toHaveTextContent("SELECTED");
await user.click(signalCards[0]);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }),
);
expect(signalCards[0]).not.toHaveTextContent("SELECTED");
});
test("renders without error", () => {
render(<CommunicationMethodsScreen />);
@@ -44,12 +98,38 @@ describe("Create flow communication-methods page", () => {
expect(
screen.getByText(/You can select multiple methods for different needs or/),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "add" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^add$/i })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "See all communication approaches" }),
).toBeInTheDocument();
});
test("with a finalized custom policy, inline add link still opens the custom wizard", async () => {
const user = userEvent.setup();
render(
<CommunicationMethodsScreenWithState
initial={{
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
customMethodCardMetaById: {
[CUSTOM_POLICY_ID]: { label: "My policy", supportText: "Desc" },
},
}}
/>,
);
expect(
screen.queryByRole("button", { name: "Remove policy" }),
).not.toBeInTheDocument();
const addButtons = screen.getAllByRole("button", { name: /^add$/i });
expect(addButtons.length).toBeGreaterThanOrEqual(1);
await user.click(addButtons[0]);
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText("What do you call your group's new policy?"),
).toBeInTheDocument();
});
test("toggle expands and shows Show less", async () => {
const user = userEvent.setup();
render(<CommunicationMethodsScreen />);
@@ -67,5 +147,122 @@ describe("Create flow communication-methods page", () => {
"What method should this community use to communicate with eachother?",
),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /^add$/i }),
).toBeInTheDocument();
});
test("opening Create modal for custom policy shows saved field blocks", async () => {
const user = userEvent.setup();
const initial = {
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
methodSectionsPinCommitted: { communication: true },
customMethodCardMetaById: {
[CUSTOM_POLICY_ID]: { label: "My policy", supportText: "Support copy" },
},
customMethodCardFieldBlocksById: {
[CUSTOM_POLICY_ID]: [
{
kind: "text",
id: "f1",
blockTitle: "Guidelines",
placeholderText: "Enter norms here",
},
],
},
};
render(<CommunicationMethodsScreenWithState initial={initial} />);
const policyTiles = screen.getAllByRole("button", {
name: /My policy: Support copy/,
});
await user.click(policyTiles[0]);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("Guidelines")).toBeInTheDocument();
const textarea = within(dialog).getByRole("textbox");
expect(textarea).not.toBeDisabled();
expect(textarea).toHaveValue("Enter norms here");
});
test("opening Create modal for custom policy shows badge options as chips", async () => {
const user = userEvent.setup();
const initial = {
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
methodSectionsPinCommitted: { communication: true },
customMethodCardMetaById: {
[CUSTOM_POLICY_ID]: { label: "My policy", supportText: "Support copy" },
},
customMethodCardFieldBlocksById: {
[CUSTOM_POLICY_ID]: [
{
kind: "badges",
id: "b1",
blockTitle: "Choose channels",
options: ["Alpha", "Beta"],
},
],
},
};
render(<CommunicationMethodsScreenWithState initial={initial} />);
const policyTiles = screen.getAllByRole("button", {
name: /My policy: Support copy/,
});
await user.click(policyTiles[0]);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("Choose channels")).toBeInTheDocument();
const alpha = within(dialog).getByRole("button", { name: /Deselect Alpha/ });
const beta = within(dialog).getByRole("button", { name: /Deselect Beta/ });
expect(alpha).not.toBeDisabled();
expect(beta).not.toBeDisabled();
});
test("editing custom policy field blocks updates draft state", async () => {
const user = userEvent.setup();
let latest = {};
function Probe({ initial }) {
const { replaceState, state } = useCreateFlow();
useLayoutEffect(() => {
replaceState(initial);
}, [replaceState, initial]);
useLayoutEffect(() => {
latest = state;
}, [state]);
return <CommunicationMethodsScreen />;
}
const initial = {
selectedCommunicationMethodIds: [CUSTOM_POLICY_ID],
customMethodCardMetaById: {
[CUSTOM_POLICY_ID]: { label: "My policy", supportText: "Support copy" },
},
customMethodCardFieldBlocksById: {
[CUSTOM_POLICY_ID]: [
{
kind: "text",
id: "f1",
blockTitle: "Guidelines",
placeholderText: "Original",
},
],
},
};
render(<Probe initial={initial} />);
const policyTiles = screen.getAllByRole("button", {
name: /My policy: Support copy/,
});
await user.click(policyTiles[0]);
const textarea = within(screen.getByRole("dialog")).getByRole("textbox");
await user.clear(textarea);
await user.type(textarea, "Updated norms");
const row = latest.customMethodCardFieldBlocksById?.[CUSTOM_POLICY_ID]?.[0];
expect(row).toMatchObject({
kind: "text",
placeholderText: "Updated norms",
});
});
});
+85 -1
View File
@@ -6,7 +6,19 @@ import {
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { describe, test, expect, afterEach } from "vitest";
import { useLayoutEffect } from "react";
import { DecisionApproachesScreen } from "../../app/(app)/create/screens/right-rail/DecisionApproachesScreen";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
const CUSTOM_APPROACH_ID = "550e8400-e29b-41d4-a716-446655440000";
function DecisionApproachesScreenWithState({ initial }) {
const { replaceState } = useCreateFlow();
useLayoutEffect(() => {
replaceState(initial);
}, [replaceState, initial]);
return <DecisionApproachesScreen />;
}
afterEach(() => {
cleanup();
@@ -27,7 +39,7 @@ describe("Create flow decision-approaches page", () => {
render(<DecisionApproachesScreen />);
const addControl = screen.getByRole("button", {
name: /^add$/,
name: /^Add$/i,
});
expect(addControl).toBeInTheDocument();
@@ -37,6 +49,31 @@ describe("Create flow decision-approaches page", () => {
expect(description?.textContent).toMatch(/new decision making approaches/);
});
test("with a finalized custom approach, sidebar still shows add (not Remove policy)", () => {
render(
<DecisionApproachesScreenWithState
initial={{
selectedDecisionApproachIds: [CUSTOM_APPROACH_ID],
customMethodCardMetaById: {
[CUSTOM_APPROACH_ID]: {
label: "My approach",
supportText: "Desc",
},
},
}}
/>,
);
expect(
screen.queryByRole("button", { name: "Remove policy" }),
).not.toBeInTheDocument();
const addControl = screen.getByRole("button", { name: /^Add$/i });
expect(addControl).toBeInTheDocument();
const description = addControl.parentElement;
expect(description?.textContent).toMatch(/Select as many as you need/);
expect(description?.textContent).toMatch(/new decision making approaches/);
});
test("renders message box with title and checkboxes", () => {
render(<DecisionApproachesScreen />);
@@ -103,6 +140,7 @@ describe("Create flow decision-approaches page", () => {
expect(
screen.getByRole("button", { name: "Show less" }),
).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^add$/i })).toHaveLength(2);
});
test("expanded view reveals additional non-recommended approaches", async () => {
@@ -143,6 +181,52 @@ describe("Create flow decision-approaches page", () => {
expect(screen.getByText("SELECTED")).toBeInTheDocument();
});
test("re-opening a selected approach shows Remove as the modal primary action", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
const card = screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
});
await user.click(card);
const dialog = await screen.findByRole("dialog");
await user.click(
within(dialog).getByRole("button", {
name: "Add Approach",
}),
);
await user.click(card);
const dialogAgain = screen.getByRole("dialog");
expect(
within(dialogAgain).getByRole("button", { name: "Remove" }),
).toBeInTheDocument();
});
test("Remove in the modal deselects the approach", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
const card = screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
});
await user.click(card);
await user.click(
within(await screen.findByRole("dialog")).getByRole("button", {
name: "Add Approach",
}),
);
expect(card).toHaveTextContent("SELECTED");
await user.click(card);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }),
);
expect(card).not.toHaveTextContent("SELECTED");
});
test("message box checkboxes are interactive", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
+19
View File
@@ -113,4 +113,23 @@ describe("CardStack Component", () => {
expect(screen.getAllByText("SELECTED").length).toBeGreaterThanOrEqual(1);
});
test("calls onCardSelect when re-clicking an already selected card", () => {
const onCardSelect = vi.fn();
render(
<CardStack
cards={SAMPLE_CARDS}
selectedIds={["1"]}
onCardSelect={onCardSelect}
title="Pick an option"
/>,
);
const cardButtons = screen.getAllByRole("button", {
name: "Option A: Description A",
});
fireEvent.click(cardButtons[0]);
expect(onCardSelect).toHaveBeenCalledWith("1");
expect(screen.queryByRole("button", { name: "Remove policy" })).not.toBeInTheDocument();
});
});
@@ -124,4 +124,46 @@ describe("applyFinalReviewChipEditPatch", () => {
expect(Object.keys(result)).toEqual(["conflictManagementDetailsById"]);
});
it("merges customMethodCardFieldBlocksById when the patch carries field blocks", () => {
const state: CreateFlowState = {
customMethodCardFieldBlocksById: {
other: [
{
kind: "text",
id: "x",
blockTitle: "T",
placeholderText: "keep",
},
],
},
};
const patch: FinalReviewChipEditPatch = {
groupKey: "communication",
overrideKey: "550e8400-e29b-41d4-a716-446655440000",
value: {
corePrinciple: "a",
logisticsAdmin: "b",
codeOfConduct: "c",
},
customMethodCardFieldBlocks: [
{
kind: "text",
id: "f1",
blockTitle: "Notes",
placeholderText: "edited",
},
],
};
const result = applyFinalReviewChipEditPatch(state, patch);
expect(result.communicationMethodDetailsById).toEqual({
"550e8400-e29b-41d4-a716-446655440000": patch.value,
});
expect(result.customMethodCardFieldBlocksById).toEqual({
other: state.customMethodCardFieldBlocksById?.other,
"550e8400-e29b-41d4-a716-446655440000": patch.customMethodCardFieldBlocks,
});
});
});
@@ -59,6 +59,19 @@ describe("buildFinalReviewCategoriesFromState", () => {
expect(rows).toEqual([{ name: "Communication", chips: ["Signal"] }]);
});
it("resolves user-authored method ids from customMethodCardMetaById", () => {
const customId = "00000000-0000-4000-8000-000000000001";
const state: CreateFlowState = {
selectedCommunicationMethodIds: ["signal", customId],
customMethodCardMetaById: {
[customId]: { label: "Our Slack Ritual", supportText: "desc" },
},
};
const rows = buildFinalReviewCategoriesFromState(state, NAMES);
const comm = rows.find((r) => r.name === "Communication");
expect(comm?.chips).toEqual(["Signal", "Our Slack Ritual"]);
});
it("dedupes repeated labels from duplicate ids", () => {
const state: CreateFlowState = {
selectedCommunicationMethodIds: ["signal", "signal"],
+19
View File
@@ -169,6 +169,25 @@ describe("buildPublishPayload — methodSelections", () => {
expect(entries?.[0]?.blocks?.length).toBeGreaterThanOrEqual(1);
});
it("uses customMethodCardMetaById label when preset id is unknown", () => {
const customId = "00000000-0000-4000-8000-000000000002";
const r = buildPublishPayload({
title: "T",
selectedCommunicationMethodIds: [customId],
customMethodCardMetaById: {
[customId]: { label: "Custom Comm", supportText: "More" },
},
});
expect(r.ok).toBe(true);
if (!r.ok) return;
const ms = r.document.methodSelections as
| { communication?: Array<{ id: string; label: string }> }
| undefined;
expect(ms?.communication?.length).toBe(1);
expect(ms?.communication?.[0]?.id).toBe(customId);
expect(ms?.communication?.[0]?.label).toBe("Custom Comm");
});
it("emits preset-only sections when a method is selected without an override", () => {
const r = buildPublishPayload({
title: "T",
+34
View File
@@ -126,6 +126,40 @@ describe("createFlowStateSchema", () => {
expect(r.success).toBe(true);
});
it("accepts customMethodCardFieldBlocksById", () => {
const r = createFlowStateSchema.safeParse({
customMethodCardFieldBlocksById: {
"card-uuid": [
{
kind: "text",
id: "f1",
blockTitle: "Notes",
placeholderText: "Optional",
},
{
kind: "badges",
id: "f2",
blockTitle: "Tags",
options: ["a", "b"],
},
{
kind: "upload",
id: "f3",
blockTitle: "Attachment",
fileName: "doc.pdf",
},
{
kind: "proportion",
id: "f4",
blockTitle: "Share",
defaultPercent: 50,
},
],
},
});
expect(r.success).toBe(true);
});
it("accepts templateReviewEntryFromCreateFlow", () => {
const r = createFlowStateSchema.safeParse({
templateReviewEntryFromCreateFlow: true,
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { isCustomMethodCardId } from "../../lib/create/isCustomMethodCardId";
describe("isCustomMethodCardId", () => {
it("is false when meta is missing or id has no entry", () => {
expect(isCustomMethodCardId("signal", undefined)).toBe(false);
expect(isCustomMethodCardId("signal", {})).toBe(false);
});
it("is true when customMethodCardMetaById has the id", () => {
const id = "550e8400-e29b-41d4-a716-446655440000";
expect(
isCustomMethodCardId(id, {
[id]: { label: "L", supportText: "S" },
}),
).toBe(true);
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { mergePresetMethodsWithCustom } from "../../lib/create/mergePresetMethodsWithCustom";
describe("mergePresetMethodsWithCustom", () => {
it("appends selected custom ids that have meta after presets", () => {
const presets = [
{ id: "a", label: "A", supportText: "sa" },
{ id: "b", label: "B", supportText: "sb" },
];
const customId = "00000000-0000-4000-8000-000000000099";
const merged = mergePresetMethodsWithCustom(
presets,
["b", customId, "a"],
{
[customId]: { label: "Custom", supportText: "cx" },
},
);
expect(merged.map((m) => m.id)).toEqual(["a", "b", customId]);
expect(merged[2]).toEqual({
id: customId,
label: "Custom",
supportText: "cx",
});
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { moveFacetSelectionIdToFront } from "../../lib/create/methodCardSelectionOrder";
describe("moveFacetSelectionIdToFront", () => {
it("places a new id at index 0", () => {
expect(moveFacetSelectionIdToFront(["a", "b"], "c")).toEqual(["c", "a", "b"]);
});
it("moves an existing id to index 0 without duplicating", () => {
expect(moveFacetSelectionIdToFront(["a", "b", "c"], "b")).toEqual([
"b",
"a",
"c",
]);
});
it("handles empty prior selection", () => {
expect(moveFacetSelectionIdToFront([], "x")).toEqual(["x"]);
});
});
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import type { CreateFlowState } from "../../app/(app)/create/types";
import { removeMethodCardFromFacetSelection } from "../../lib/create/removeMethodCardFromFacetSelection";
const CUSTOM_A = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
describe("removeMethodCardFromFacetSelection", () => {
it("returns {} when the card is not in the facet selection", () => {
const state: CreateFlowState = {
selectedCommunicationMethodIds: ["signal"],
};
expect(
removeMethodCardFromFacetSelection(state, "communication", "loomio"),
).toEqual({});
});
it("removes a preset id, its details, and leaves other selections", () => {
const state: CreateFlowState = {
selectedCommunicationMethodIds: ["signal", "slack"],
communicationMethodDetailsById: {
signal: {} as never,
slack: {} as never,
},
};
const patch = removeMethodCardFromFacetSelection(
state,
"communication",
"signal",
);
expect(patch.selectedCommunicationMethodIds).toEqual(["slack"]);
expect(patch.communicationMethodDetailsById).toEqual({ slack: {} });
});
it("removes a custom card id and clears meta + field blocks", () => {
const state: CreateFlowState = {
selectedCommunicationMethodIds: ["signal", CUSTOM_A],
communicationMethodDetailsById: {
signal: {} as never,
[CUSTOM_A]: {} as never,
},
customMethodCardMetaById: {
[CUSTOM_A]: { label: "P", supportText: "D" },
},
customMethodCardFieldBlocksById: {
[CUSTOM_A]: [],
},
};
const patch = removeMethodCardFromFacetSelection(
state,
"communication",
CUSTOM_A,
);
expect(patch.selectedCommunicationMethodIds).toEqual(["signal"]);
expect(patch.communicationMethodDetailsById).toEqual({ signal: {} });
expect(patch.customMethodCardMetaById).toBeUndefined();
expect(patch.customMethodCardFieldBlocksById).toBeUndefined();
});
});
@@ -15,6 +15,19 @@ describe("stripCustomRuleSelectionFields", () => {
selectedConflictManagementIds: ["z"],
methodSectionsPinCommitted: { communication: true },
coreValueDetailsByChipId: { "1": { meaning: "", signals: "" } },
customMethodCardMetaById: {
x: { label: "Custom", supportText: "S" },
},
customMethodCardFieldBlocksById: {
x: [
{
kind: "text",
id: "f1",
blockTitle: "T",
placeholderText: "",
},
],
},
sections: [{ categoryName: "Communication", entries: [] }],
};
const out = stripCustomRuleSelectionFields(prev);
@@ -28,5 +41,7 @@ describe("stripCustomRuleSelectionFields", () => {
expect(out.selectedConflictManagementIds).toBeUndefined();
expect(out.methodSectionsPinCommitted).toBeUndefined();
expect(out.coreValueDetailsByChipId).toBeUndefined();
expect(out.customMethodCardMetaById).toBeUndefined();
expect(out.customMethodCardFieldBlocksById).toBeUndefined();
});
});