Open each core value as a card of meaning and signals steps #98

Merged
an.di merged 2 commits from adilallo/fix/CR-280-values-step into main 2026-11-06 05:18:04 +00:00
25 changed files with 979 additions and 283 deletions

No files matched your search

@@ -1065,6 +1065,30 @@ function CreateFlowLayoutContent({
</div> </div>
) : showCustomRuleFooterConfirm && ) : showCustomRuleFooterConfirm &&
customRuleConfirmFooter ? ( customRuleConfirmFooter ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
{customRuleConfirmFooter.step === "core-values" ? (
<Button
buttonType="ghost"
palette="default"
size="xsmall"
disabled={isPublishing}
className={CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS}
onClick={() => {
if (reviewReturnTarget) {
router.push(
createFlowStepPathAfterStrippingReviewReturn(
reviewReturnTarget,
searchParams,
),
);
return;
}
goToNextStep();
}}
>
{footer.skipForNow}
</Button>
) : null}
<Button <Button
buttonType="filled" buttonType="filled"
palette="default" palette="default"
@@ -1094,6 +1118,7 @@ function CreateFlowLayoutContent({
> >
{footer[customRuleConfirmFooter.footerMessageKey]} {footer[customRuleConfirmFooter.footerMessageKey]}
</Button> </Button>
</div>
) : isConfirmStakeholdersManagePublished ? ( ) : isConfirmStakeholdersManagePublished ? (
<Button <Button
buttonType="filled" buttonType="filled"
@@ -244,7 +244,6 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
titleTrim.length > 0 && titleTrim.length > 0 &&
titleTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS; titleTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
const descriptionOk = const descriptionOk =
descriptionTrim.length > 0 &&
descriptionTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS; descriptionTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS;
if (wizardStep === 1) return titleOk; if (wizardStep === 1) return titleOk;
if (wizardStep === 2) return descriptionOk; if (wizardStep === 2) return descriptionOk;
@@ -1,10 +1,9 @@
"use client"; "use client";
/** /**
* Final-review chip modal: **Core values** and **method** facets share the * Final-review chip modal: **Core values** use {@link ValueDialog} (Deselect +
* kebab → **Duplicate** (values only when under the cap) / **Remove** pattern * Cancel + Save; kebab Customize / Duplicate under the cap). **Method** facets
* from the create-card facet modals (`Create` + * keep `Create` with kebab Duplicate/Remove plus a footer **Remove** when the
* {@link buildCustomRuleModalKebabMenu}), plus a footer **Remove** when the
* chip is already in the selection. Values and method chips also offer * chip is already in the selection. Values and method chips also offer
* **Customize**, which opens {@link CustomMethodCardWizard} prefilled from the * **Customize**, which opens {@link CustomMethodCardWizard} prefilled from the
* chip. Fields are editable on open; Save persists body edits without renaming. * chip. Fields are editable on open; Save persists body edits without renaming.
@@ -22,10 +21,10 @@ import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
import { import {
CommunicationMethodEditFields, CommunicationMethodEditFields,
ConflictManagementEditFields, ConflictManagementEditFields,
CoreValueEditFields,
DecisionApproachEditFields, DecisionApproachEditFields,
MembershipMethodEditFields, MembershipMethodEditFields,
} from "./methodEditFields"; } from "./methodEditFields";
import { ValueDialog } from "./ValueDialog";
import CustomMethodCardModalBody from "./CustomMethodCardModalBody"; import CustomMethodCardModalBody from "./CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu";
import { useDiscardCustomizeConfirm } from "../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../hooks/useDiscardCustomizeConfirm";
@@ -34,6 +33,7 @@ import {
communicationPresetFor, communicationPresetFor,
conflictManagementPresetFor, conflictManagementPresetFor,
coreValuePresetFor, coreValuePresetFor,
coreValuePresetForLabel,
decisionApproachPresetFor, decisionApproachPresetFor,
emptyCommunicationDetails, emptyCommunicationDetails,
emptyConflictManagementDetails, emptyConflictManagementDetails,
@@ -66,6 +66,7 @@ import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
import CustomMethodCardWizard from "./CustomMethodCardWizard"; import CustomMethodCardWizard from "./CustomMethodCardWizard";
import { import {
duplicateCoreValueChipInDraft, duplicateCoreValueChipInDraft,
MAX_SELECTED_CORE_VALUES,
removeCoreValueChipFromDraft, removeCoreValueChipFromDraft,
} from "../../../../lib/create/coreValueChipFacet"; } from "../../../../lib/create/coreValueChipFacet";
import { import {
@@ -485,8 +486,7 @@ export function FinalReviewChipEditModal({
buildMethodCardWizardInitialValues({ buildMethodCardWizardInitialValues({
cardId: target.overrideKey, cardId: target.overrideKey,
fallbackTitle: target.chipLabel, fallbackTitle: target.chipLabel,
fallbackDescription: fallbackDescription: value.supportText?.trim() || "",
value.supportText?.trim() || tCv.detailModal.subtitle,
meta: {}, meta: {},
persistedBlocks: state.customMethodCardFieldBlocksById, persistedBlocks: state.customMethodCardFieldBlocksById,
draftFieldBlocks: null, draftFieldBlocks: null,
@@ -559,7 +559,6 @@ export function FinalReviewChipEditModal({
tComm.sectionHeadings, tComm.sectionHeadings,
tCv.detailModal.meaningLabel, tCv.detailModal.meaningLabel,
tCv.detailModal.signalsLabel, tCv.detailModal.signalsLabel,
tCv.detailModal.subtitle,
tDa.sectionHeadings, tDa.sectionHeadings,
tMem.sectionHeadings, tMem.sectionHeadings,
target, target,
@@ -652,7 +651,7 @@ export function FinalReviewChipEditModal({
if ((state.editingPublishedRuleId?.trim() ?? "") !== "") { if ((state.editingPublishedRuleId?.trim() ?? "") !== "") {
return; return;
} }
if ((state.selectedCoreValueIds ?? []).length >= 5) { if ((state.selectedCoreValueIds ?? []).length >= MAX_SELECTED_CORE_VALUES) {
return; return;
} }
if ( if (
@@ -727,11 +726,9 @@ export function FinalReviewChipEditModal({
onCustomize: handleCustomize, onCustomize: handleCustomize,
onDuplicate: onDuplicate:
(state.editingPublishedRuleId?.trim() ?? "") !== "" || (state.editingPublishedRuleId?.trim() ?? "") !== "" ||
(state.selectedCoreValueIds ?? []).length >= 5 (state.selectedCoreValueIds ?? []).length >= MAX_SELECTED_CORE_VALUES
? undefined ? undefined
: handleDuplicateCoreValue, : handleDuplicateCoreValue,
showRemove: true,
onRemove: handleRemoveCoreValueFromModal,
}); });
} }
if (!isMethodFacetGroup(target.groupKey)) return []; if (!isMethodFacetGroup(target.groupKey)) return [];
@@ -744,7 +741,6 @@ export function FinalReviewChipEditModal({
}, [ }, [
handleCustomize, handleCustomize,
handleDuplicateCoreValue, handleDuplicateCoreValue,
handleRemoveCoreValueFromModal,
handleRemoveSelectedFromModal, handleRemoveSelectedFromModal,
isChipInSelection, isChipInSelection,
modalKebabMenu, modalKebabMenu,
@@ -780,8 +776,12 @@ export function FinalReviewChipEditModal({
return; return;
} }
onInteract?.(); onInteract?.();
const existingBlocks = const persistedBlocks =
state.customMethodCardFieldBlocksById?.[target.overrideKey]; state.customMethodCardFieldBlocksById?.[target.overrideKey];
const existingBlocks =
draftFieldBlocks && draftFieldBlocks.length > 0
? draftFieldBlocks
: persistedBlocks;
onSave({ onSave({
groupKey: "coreValues", groupKey: "coreValues",
overrideKey: target.overrideKey, overrideKey: target.overrideKey,
@@ -1080,12 +1080,61 @@ export function FinalReviewChipEditModal({
const showFooterRemove = const showFooterRemove =
target != null && target != null &&
(target.groupKey === "coreValues" || isChipInSelection); target.groupKey !== "coreValues" &&
isChipInSelection;
const isCoreValuesTarget = target?.groupKey === "coreValues";
const coreDraftValue =
draft?.groupKey === "coreValues" ? draft.value : { meaning: "", signals: "" };
return ( return (
<> <>
<ValueDialog
isOpen={isOpen && !addCustomWizardOpen && isCoreValuesTarget}
valueLabel={target?.chipLabel ?? ""}
session="editing"
draft={coreDraftValue}
onDraftChange={(value) => setDraft({ groupKey: "coreValues", value })}
generateDraft={
target
? catalogCoreValueDraft(target.overrideKey, target.chipLabel)
: undefined
}
onClose={() => {
void handleModalClose();
}}
onConfirm={handleNext}
onDeselect={handleRemoveCoreValueFromModal}
nextButtonDisabled={coreCustomizeSaveDisabled}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={
isCoreValuesTarget && kebabMenuItems.length > 0
? kebabMenuItems
: undefined
}
ariaLabel={target?.chipLabel || "Edit chip details"}
>
{isCoreValuesTarget && modalUsesWizardFieldBlocksBody && target ? (
<CustomMethodCardModalBody
cardId={target.overrideKey}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
showPolicyContentLockupWhenNoBlocks={false}
onFieldBlocksChange={
draftFieldBlocks === null
? undefined
: handleWizardFieldBlocksChange
}
/>
) : undefined}
</ValueDialog>
<Create <Create
isOpen={isOpen && !addCustomWizardOpen} isOpen={
isOpen && !addCustomWizardOpen && !isCoreValuesTarget
}
onClose={handleModalClose} onClose={handleModalClose}
onBack={handleModalClose} onBack={handleModalClose}
showBackButton={!showFooterRemove} showBackButton={!showFooterRemove}
@@ -1113,29 +1162,6 @@ export function FinalReviewChipEditModal({
ariaLabel={target?.chipLabel || "Edit chip details"} ariaLabel={target?.chipLabel || "Edit chip details"}
> >
<div className="flex flex-col gap-[var(--space-600)] pb-2"> <div className="flex flex-col gap-[var(--space-600)] pb-2">
{draft?.groupKey === "coreValues" &&
target &&
(modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={target.overrideKey}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
showPolicyContentLockupWhenNoBlocks={false}
onFieldBlocksChange={
draftFieldBlocks === null
? undefined
: handleWizardFieldBlocksChange
}
/>
) : (
<CoreValueEditFields
value={draft.value}
onChange={(value) => setDraft({ groupKey: "coreValues", value })}
generateDraft={coreValuePresetFor(target.overrideKey)}
/>
))}
{draft?.groupKey === "communication" && {draft?.groupKey === "communication" &&
target && target &&
(modalUsesWizardFieldBlocksBody ? ( (modalUsesWizardFieldBlocksBody ? (
@@ -1451,3 +1477,12 @@ function findMethodSupportText(
} }
return ""; return "";
} }
function catalogCoreValueDraft(
chipId: string,
label: string,
): CoreValueDetailEntry {
const fromId = coreValuePresetFor(chipId);
if (fromId.meaning.trim() || fromId.signals.trim()) return fromId;
return coreValuePresetForLabel(label);
}
@@ -16,6 +16,8 @@ export interface ModalTextAreaFieldProps {
label: string; label: string;
/** Show the help "?" icon next to the label. Off until tooltip behavior ships. */ /** Show the help "?" icon next to the label. Off until tooltip behavior ships. */
helpIcon?: boolean; helpIcon?: boolean;
/** Hide the label when the modal title already asks the question. */
hideLabel?: boolean;
/** Current text value. */ /** Current text value. */
value: string; value: string;
/** Fired on every change with the new value (no event). */ /** Fired on every change with the new value (no event). */
@@ -35,6 +37,12 @@ export interface ModalTextAreaFieldProps {
generateDraftLabel?: string; generateDraftLabel?: string;
/** Hint beside Generate draft. */ /** Hint beside Generate draft. */
generateDraftHint?: string; generateDraftHint?: string;
/**
* Shown under a filled field instead of Generate draft (picked value
* field view).
*/
filledHint?: string;
autoFocus?: boolean;
className?: string; className?: string;
} }
@@ -63,6 +71,7 @@ function PlusGlyph() {
function ModalTextAreaFieldComponent({ function ModalTextAreaFieldComponent({
label, label,
helpIcon = false, helpIcon = false,
hideLabel = false,
value, value,
onChange, onChange,
rows = 4, rows = 4,
@@ -71,18 +80,25 @@ function ModalTextAreaFieldComponent({
generateDraftText, generateDraftText,
generateDraftLabel, generateDraftLabel,
generateDraftHint, generateDraftHint,
filledHint,
autoFocus = false,
className = "", className = "",
}: ModalTextAreaFieldProps) { }: ModalTextAreaFieldProps) {
const labelId = useId(); const labelId = useId();
const draftSource = generateDraftText?.trim() ?? ""; const draftSource = generateDraftText?.trim() ?? "";
const filled = value.trim().length > 0;
const showFilledHint = filled && Boolean(filledHint) && !disabled;
const showGenerateDraft = const showGenerateDraft =
draftSource.length > 0 && Boolean(generateDraftLabel); !showFilledHint &&
const compose = showGenerateDraft; draftSource.length > 0 &&
Boolean(generateDraftLabel);
const compose = showGenerateDraft || showFilledHint || hideLabel;
return ( return (
<div <div
className={`flex flex-col ${compose ? "gap-[12px]" : "gap-2"} ${className}`.trim()} className={`flex flex-col ${compose ? "gap-[12px]" : "gap-2"} ${className}`.trim()}
> >
{hideLabel ? null : (
<div id={labelId}> <div id={labelId}>
<InputLabel <InputLabel
label={label} label={label}
@@ -91,6 +107,7 @@ function ModalTextAreaFieldComponent({
palette="default" palette="default"
/> />
</div> </div>
)}
<TextArea <TextArea
formHeader={false} formHeader={false}
value={value} value={value}
@@ -100,7 +117,9 @@ function ModalTextAreaFieldComponent({
appearance={compose ? "compose" : "embedded"} appearance={compose ? "compose" : "embedded"}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled}
aria-labelledby={labelId} autoFocus={autoFocus}
aria-label={hideLabel ? label : undefined}
aria-labelledby={hideLabel ? undefined : labelId}
/> />
{showGenerateDraft && !disabled ? ( {showGenerateDraft && !disabled ? (
<div className="flex items-center"> <div className="flex items-center">
@@ -119,6 +138,11 @@ function ModalTextAreaFieldComponent({
) : null} ) : null}
</div> </div>
) : null} ) : null}
{showFilledHint ? (
<p className="text-[length:var(--sizing-300)] leading-normal text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
{filledHint}
</p>
) : null}
</div> </div>
); );
} }
+181
View File
@@ -0,0 +1,181 @@
"use client";
/**
* Figma: Overlay / Modal / Card (24973:1852) with Has Text off and Has Steps
* on. A value opens a card of meaning + signals rows; each row opens that
* field's Step Modal (23746:49780) with Generate draft on an empty field.
*/
import { memo, useCallback, useEffect, useState, type ReactNode } from "react";
import Create from "../../../components/modals/Create";
import { useMessages } from "../../../contexts/MessagesContext";
import type { ModalHeaderMenuItem } from "../../../components/modals/ModalHeader/ModalHeader.types";
import type { CoreValueDetailEntry } from "../types";
import ModalTextAreaField from "./ModalTextAreaField";
import { ValueDialogStepCard } from "./ValueDialogStepCard";
export type ValueDialogSession = "pending" | "editing";
export type ValueDialogStep = "meaning" | "signals";
export type ValueDialogProps = {
isOpen: boolean;
valueLabel: string;
session: ValueDialogSession | null;
draft: CoreValueDetailEntry;
onDraftChange: (_next: CoreValueDetailEntry) => void;
generateDraft?: CoreValueDetailEntry;
onClose: () => void;
onConfirm: () => void;
onDeselect?: () => void;
nextButtonDisabled?: boolean;
kebabTriggerAriaLabel?: string;
kebabMenuAriaLabel?: string;
kebabMenuItems?: ModalHeaderMenuItem[];
ariaLabel?: string;
/** Customized field-blocks body. Replaces the meaning/signals step cards. */
children?: ReactNode;
};
function ValueDialogComponent({
isOpen,
valueLabel,
session,
draft,
onDraftChange,
generateDraft,
onClose,
onConfirm,
onDeselect,
nextButtonDisabled = false,
kebabTriggerAriaLabel,
kebabMenuAriaLabel,
kebabMenuItems,
ariaLabel,
children,
}: ValueDialogProps) {
const m = useMessages();
const t = m.create.customRule.coreValues.detailModal;
const draftField = m.create.draftField;
const [activeStep, setActiveStep] = useState<ValueDialogStep | null>(null);
useEffect(() => {
if (!isOpen) {
setActiveStep(null);
}
}, [isOpen]);
const handleRequestClose = useCallback(() => {
if (activeStep != null) {
setActiveStep(null);
return;
}
onClose();
}, [activeStep, onClose]);
const meaningDone = draft.meaning.trim().length > 0;
const signalsDone = draft.signals.trim().length > 0;
const useCustomBody = Boolean(children);
const showSteps = !useCustomBody && activeStep == null;
const editing = session === "editing";
const primaryLabel = editing ? t.save : t.addToRule;
const stepTitle =
activeStep === "meaning"
? t.meaningStepTitle.replace("{value}", valueLabel)
: activeStep === "signals"
? t.signalsLabel
: valueLabel;
const stepDescription =
activeStep === "meaning"
? t.meaningDescription
: activeStep === "signals"
? t.signalsStepDescription.replace("{value}", valueLabel)
: undefined;
return (
<Create
isOpen={isOpen}
onClose={handleRequestClose}
title={stepTitle}
description={activeStep != null ? stepDescription : undefined}
showBackButton={false}
showCancelButton
showNextButton
showRemoveButton={editing && activeStep == null}
onCancel={handleRequestClose}
onNext={onConfirm}
onRemove={onDeselect}
cancelButtonText={t.cancel}
nextButtonText={primaryLabel}
removeButtonText={t.deselect}
nextButtonDisabled={nextButtonDisabled}
stepper={false}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={kebabTriggerAriaLabel}
kebabMenuAriaLabel={kebabMenuAriaLabel}
kebabMenuItems={
activeStep == null && kebabMenuItems && kebabMenuItems.length > 0
? kebabMenuItems
: undefined
}
ariaLabel={ariaLabel || valueLabel}
>
{useCustomBody ? (
children
) : showSteps ? (
<div className="flex w-full flex-col gap-[8px]">
<ValueDialogStepCard
stepNumber={1}
stepBadgeLabel={t.stepBadge}
title={t.meaningLabel}
description={
meaningDone ? draft.meaning : t.meaningDescription
}
done={meaningDone}
onClick={() => setActiveStep("meaning")}
/>
<ValueDialogStepCard
stepNumber={2}
stepBadgeLabel={t.stepBadge}
title={t.signalsLabel}
description={
signalsDone ? draft.signals : t.signalsDescription
}
done={signalsDone}
onClick={() => setActiveStep("signals")}
/>
</div>
) : activeStep === "meaning" ? (
<ModalTextAreaField
hideLabel
label={t.meaningLabel}
value={draft.meaning}
onChange={(meaning) => onDraftChange({ ...draft, meaning })}
placeholder={t.placeholder}
generateDraftText={generateDraft?.meaning}
generateDraftLabel={draftField.generateDraft}
generateDraftHint={t.generateDraftHint}
filledHint={t.filledHint}
autoFocus
/>
) : (
<ModalTextAreaField
hideLabel
label={t.signalsLabel}
value={draft.signals}
onChange={(signals) => onDraftChange({ ...draft, signals })}
placeholder={t.placeholder}
generateDraftText={generateDraft?.signals}
generateDraftLabel={draftField.generateDraft}
generateDraftHint={t.generateDraftHint}
filledHint={t.filledHint}
autoFocus
/>
)}
</Create>
);
}
ValueDialogComponent.displayName = "ValueDialog";
export const ValueDialog = memo(ValueDialogComponent);
@@ -0,0 +1,100 @@
"use client";
/**
* Figma: ".utility / Step Card" (23742:49583). One row in Overlay / Modal / Card
* with Has Steps on — open (step badge) or done (check).
*/
import { memo } from "react";
import Icon from "../../../components/asset/icon";
export type ValueDialogStepCardProps = {
stepNumber: number;
stepBadgeLabel: string;
title: string;
description: string;
done: boolean;
onClick: () => void;
};
function CheckGlyph() {
return (
<svg
aria-hidden
width="10"
height="10"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-[var(--color-content-invert-primary,white)]"
>
<path
d="M10 3L4.5 8.5L2 6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ValueDialogStepCardComponent({
stepNumber,
stepBadgeLabel,
title,
description,
done,
onClick,
}: ValueDialogStepCardProps) {
return (
<button
type="button"
onClick={onClick}
aria-label={title}
data-figma-node={done ? "23740:49582" : "23740:49581"}
className={`flex w-full shrink-0 items-center gap-[8px] rounded-[12px] border border-solid p-[12px] text-left ${
done
? "border-[var(--color-border-default-positive-primary,#45ea97)] bg-[var(--color-surface-default-secondary,#141414)]"
: "border-[var(--color-border-default-secondary,#303030)] bg-[var(--color-surface-default-secondary,#141414)] hover:border-[var(--color-border-default-brand-primary,#fdfaa8)]"
} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]`}
>
{done ? (
<span
aria-hidden
className="flex size-4 shrink-0 items-center justify-center rounded-[8px] bg-[var(--color-surface-default-positive-primary,#16c472)]"
>
<CheckGlyph />
</span>
) : (
<span
aria-hidden
className="flex w-[48px] shrink-0 items-center justify-center rounded-[12px] bg-[var(--color-surface-default-primary)] py-[4px] text-[length:var(--sizing-250)] font-medium leading-[12px] text-[color:var(--color-content-default-secondary,#c2c2c2)]"
>
{stepBadgeLabel.replace("{n}", String(stepNumber))}
</span>
)}
<span className="flex min-w-0 flex-1 flex-col items-start gap-[2px]">
<span className="w-full text-x-small-paragraph text-[color:var(--color-content-default-primary)]">
{title}
</span>
<span
className={`w-full text-[length:var(--sizing-250)] leading-[14px] ${
done
? "text-[color:var(--color-content-default-secondary,#c2c2c2)]"
: "text-[color:var(--color-content-default-tertiary,#a4a4a4)]"
}`}
>
{description}
</span>
</span>
<span className="flex size-4 shrink-0 items-center justify-center text-[color:var(--color-content-default-primary)]">
<Icon name="chevron_right" size={16} />
</span>
</button>
);
}
ValueDialogStepCardComponent.displayName = "ValueDialogStepCard";
export const ValueDialogStepCard = memo(ValueDialogStepCardComponent);
@@ -1,74 +0,0 @@
"use client";
/**
* Controlled meaning/signals field set for a core-value chip. Rendered both
* by `core-values` (custom-rule selection step) and `final-review` (chip
* edit modal). Holds no state — the parent owns the draft and decides when
* to persist (`updateState`) or discard.
*/
import { memo, useCallback } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import ModalTextAreaField from "../ModalTextAreaField";
import type { CoreValueDetailEntry } from "../../types";
export interface CoreValueEditFieldsProps {
value: CoreValueDetailEntry;
onChange: (_next: CoreValueDetailEntry) => void;
/** Disable meaning/signals. Facet and final-review callers omit this so fields stay editable on open. */
readOnly?: boolean;
/** Catalog copy inserted by Generate draft. */
generateDraft?: CoreValueDetailEntry;
}
function CoreValueEditFieldsComponent({
value,
onChange,
readOnly = false,
generateDraft,
}: CoreValueEditFieldsProps) {
const m = useMessages();
const t = m.create.customRule.coreValues.detailModal;
const draftField = m.create.draftField;
const patch = useCallback(
<K extends keyof CoreValueDetailEntry>(
key: K,
next: CoreValueDetailEntry[K],
) => {
onChange({ ...value, [key]: next });
},
[value, onChange],
);
return (
<div className="flex flex-col gap-[var(--space-600)] pb-2">
<ModalTextAreaField
label={t.meaningLabel}
value={value.meaning}
onChange={(v) => patch("meaning", v)}
rows={4}
disabled={readOnly}
placeholder={draftField.placeholder}
generateDraftText={generateDraft?.meaning}
generateDraftLabel={draftField.generateDraft}
generateDraftHint={draftField.generateDraftHint}
/>
<ModalTextAreaField
label={t.signalsLabel}
value={value.signals}
onChange={(v) => patch("signals", v)}
rows={4}
disabled={readOnly}
placeholder={draftField.placeholder}
generateDraftText={generateDraft?.signals}
generateDraftLabel={draftField.generateDraft}
generateDraftHint={draftField.generateDraftHint}
/>
</div>
);
}
CoreValueEditFieldsComponent.displayName = "CoreValueEditFields";
export default memo(CoreValueEditFieldsComponent);
@@ -1,6 +1,3 @@
export { default as CoreValueEditFields } from "./CoreValueEditFields";
export type { CoreValueEditFieldsProps } from "./CoreValueEditFields";
export { default as CommunicationMethodEditFields } from "./CommunicationMethodEditFields"; export { default as CommunicationMethodEditFields } from "./CommunicationMethodEditFields";
export type { CommunicationMethodEditFieldsProps } from "./CommunicationMethodEditFields"; export type { CommunicationMethodEditFieldsProps } from "./CommunicationMethodEditFields";
@@ -3,8 +3,7 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import MultiSelect from "../../../../components/controls/MultiSelect"; import MultiSelect from "../../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types"; import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
import Create from "../../../../components/modals/Create"; import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import ContentLockup from "../../../../components/type/ContentLockup";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft"; import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -16,8 +15,8 @@ import type {
} from "../../types"; } from "../../types";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { CoreValueEditFields } from "../../components/methodEditFields";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody"; import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { ValueDialog } from "../../components/ValueDialog";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody"; import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu"; import { buildCustomRuleModalKebabMenu } from "../../components/customRuleModalKebabMenu";
@@ -49,8 +48,8 @@ const MAX_CORE_VALUES = MAX_SELECTED_CORE_VALUES;
* - `editing` — chip already exists & is selected; modal reopened to * - `editing` — chip already exists & is selected; modal reopened to
* tweak meaning/signals. Dismiss = no-op (chip stays as-is). * tweak meaning/signals. Dismiss = no-op (chip stays as-is).
* *
* **Add value** (and the header "add" link) opens an empty custom-policy * The header **add your own** link opens an empty custom-policy wizard.
* wizard. Finalize writes a selected chip named from the wizard title. * Finalize writes a selected chip named from the wizard title.
*/ */
type ModalSession = "pending" | "customPending" | "editing"; type ModalSession = "pending" | "customPending" | "editing";
@@ -98,7 +97,7 @@ function chipOptionsToSnapshotRows(
const EMPTY_DETAIL: CoreValueDetailEntry = { meaning: "", signals: "" }; const EMPTY_DETAIL: CoreValueDetailEntry = { meaning: "", signals: "" };
/** Create Custom — Core Values (Figma `20264:68378`). Up to six selections; preset list + custom chips. */ /** Create Custom — Core Values (Figma `20264:68378`). Overlay / Modal / Card `24973:1852`. Up to six selections; preset list + custom chips. */
export function CoreValuesSelectScreen() { export function CoreValuesSelectScreen() {
const m = useMessages(); const m = useMessages();
const cv = m.create.customRule.coreValues; const cv = m.create.customRule.coreValues;
@@ -448,8 +447,6 @@ export function CoreValuesSelectScreen() {
}), }),
); );
const showFooterPrimary = Boolean(modalSession);
const handleCustomize = useCallback(() => { const handleCustomize = useCallback(() => {
if (!activeModalChipId) return; if (!activeModalChipId) return;
markCreateFlowInteraction(); markCreateFlowInteraction();
@@ -457,8 +454,7 @@ export function CoreValuesSelectScreen() {
buildMethodCardWizardInitialValues({ buildMethodCardWizardInitialValues({
cardId: activeModalChipId, cardId: activeModalChipId,
fallbackTitle: modalChipLabel, fallbackTitle: modalChipLabel,
fallbackDescription: fallbackDescription: draft.supportText?.trim() || "",
draft.supportText?.trim() || cv.detailModal.subtitle,
meta: {}, meta: {},
persistedBlocks: state.customMethodCardFieldBlocksById, persistedBlocks: state.customMethodCardFieldBlocksById,
draftFieldBlocks, draftFieldBlocks,
@@ -478,7 +474,6 @@ export function CoreValuesSelectScreen() {
activeModalChipId, activeModalChipId,
cv.detailModal.meaningLabel, cv.detailModal.meaningLabel,
cv.detailModal.signalsLabel, cv.detailModal.signalsLabel,
cv.detailModal.subtitle,
draft, draft,
draftFieldBlocks, draftFieldBlocks,
markCreateFlowInteraction, markCreateFlowInteraction,
@@ -606,15 +601,12 @@ export function CoreValuesSelectScreen() {
atSelectionLimit atSelectionLimit
? undefined ? undefined
: handleDuplicateCoreChip, : handleDuplicateCoreChip,
showRemove: modalSession === "editing",
onRemove: handleRemoveFromKebab,
}); });
}, [ }, [
activeModalChipId, activeModalChipId,
atSelectionLimit, atSelectionLimit,
handleCustomize, handleCustomize,
handleDuplicateCoreChip, handleDuplicateCoreChip,
handleRemoveFromKebab,
modalKebabMenu, modalKebabMenu,
modalSession, modalSession,
state.editingPublishedRuleId, state.editingPublishedRuleId,
@@ -677,7 +669,7 @@ export function CoreValuesSelectScreen() {
queueMicrotask(() => { queueMicrotask(() => {
syncCoreValuesToDraft(next); syncCoreValuesToDraft(next);
// Both branches treat the chip as a brand-new draft until the // Both branches treat the chip as a brand-new draft until the
// user confirms via Add Value — dismissal removes it. // user confirms via Add to Rule — dismissal removes it.
openModal(chipId, "customPending", value); openModal(chipId, "customPending", value);
}); });
return next; return next;
@@ -695,26 +687,17 @@ export function CoreValuesSelectScreen() {
const description = ( const description = (
<> <>
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]"> {cv.header.descriptionLead}
{cv.header.descriptionLead}{" "} <InlineTextButton
</span>
<button
type="button"
onClick={addHandlers.onAddClick} onClick={addHandlers.onAddClick}
disabled={atSelectionLimit} disabled={atSelectionLimit}
className="cursor-pointer font-normal leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90 disabled:cursor-not-allowed disabled:no-underline disabled:opacity-60 disabled:hover:opacity-60"
> >
{cv.header.addLink} {cv.header.addLink}
</button> </InlineTextButton>
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
{" "}
{cv.header.descriptionTrail} {cv.header.descriptionTrail}
</span>
</> </>
); );
const detailModal = cv.detailModal;
return ( return (
<> <>
<CreateFlowTwoColumnSelectShell <CreateFlowTwoColumnSelectShell
@@ -735,39 +718,25 @@ export function CoreValuesSelectScreen() {
onAddClick={addHandlers.onAddClick} onAddClick={addHandlers.onAddClick}
onCustomChipConfirm={addHandlers.onCustomChipConfirm} onCustomChipConfirm={addHandlers.onCustomChipConfirm}
onCustomChipClose={addHandlers.onCustomChipClose} onCustomChipClose={addHandlers.onCustomChipClose}
addButton addButton={false}
addButtonText={cv.multiSelect.addButtonText}
maxSelections={MAX_CORE_VALUES} maxSelections={MAX_CORE_VALUES}
selectionCountText={selectionCountText} selectionCountText={selectionCountText}
limitReachedAnnouncement={cv.multiSelect.limitReached} limitReachedAnnouncement={cv.multiSelect.limitReached}
/> />
{detailModal && ( <ValueDialog
<Create
isOpen={activeModalChipId !== null && !addCustomWizardOpen} isOpen={activeModalChipId !== null && !addCustomWizardOpen}
onClose={handleModalDismiss} valueLabel={modalChipLabel}
backdropVariant="blurredYellow" session={modalSession === "editing" ? "editing" : "pending"}
headerContent={ draft={draft}
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0"> onDraftChange={handleDraftChange}
<ContentLockup generateDraft={getPresetTexts(modalChipLabel)}
title={modalChipLabel} onClose={() => {
description={ void handleModalDismiss();
draft.supportText?.trim() || detailModal.subtitle }}
} onConfirm={handleModalConfirm}
variant="modal" onDeselect={
alignment="left" modalSession === "editing" ? handleRemoveFromKebab : undefined
/>
</div>
}
showBackButton={false}
showNextButton={showFooterPrimary}
showRemoveButton={modalSession === "editing"}
onRemove={handleRemoveFromKebab}
onNext={handleModalConfirm}
nextButtonText={
modalSession === "editing"
? modalKebabMenu.saveEdits
: detailModal.addValueButton
} }
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
@@ -795,15 +764,8 @@ export function CoreValuesSelectScreen() {
} }
} }
/> />
) : ( ) : undefined}
<CoreValueEditFields </ValueDialog>
value={draft}
onChange={handleDraftChange}
generateDraft={getPresetTexts(modalChipLabel)}
/>
)}
</Create>
)}
</CreateFlowTwoColumnSelectShell> </CreateFlowTwoColumnSelectShell>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
@@ -63,7 +63,7 @@ function MultiSelectView({
{!formHeader || !label ? ( {!formHeader || !label ? (
headerHelper && typeof headerHelper === "string" ? ( headerHelper && typeof headerHelper === "string" ? (
<p className="w-full text-x-small-paragraph text-[color:var(--color-content-default-tertiary)]"> <p className="w-full text-right text-x-small-paragraph text-[color:var(--color-content-default-tertiary)]">
{headerHelper} {headerHelper}
</p> </p>
) : null ) : null
@@ -21,11 +21,14 @@ const CreateContainer = memo<CreateProps>(
footerClassName, footerClassName,
showBackButton = true, showBackButton = true,
showNextButton = true, showNextButton = true,
showCancelButton = false,
showRemoveButton = false, showRemoveButton = false,
onBack, onBack,
onNext, onNext,
onCancel,
onRemove, onRemove,
backButtonText = "Back", backButtonText = "Back",
cancelButtonText,
nextButtonText = "Next", nextButtonText = "Next",
removeButtonText, removeButtonText,
nextButtonDisabled = false, nextButtonDisabled = false,
@@ -59,11 +62,14 @@ const CreateContainer = memo<CreateProps>(
footerClassName={footerClassName} footerClassName={footerClassName}
showBackButton={showBackButton} showBackButton={showBackButton}
showNextButton={showNextButton} showNextButton={showNextButton}
showCancelButton={showCancelButton}
showRemoveButton={showRemoveButton} showRemoveButton={showRemoveButton}
onBack={onBack} onBack={onBack}
onNext={onNext} onNext={onNext}
onCancel={onCancel}
onRemove={onRemove} onRemove={onRemove}
backButtonText={backButtonText} backButtonText={backButtonText}
cancelButtonText={cancelButtonText}
nextButtonText={nextButtonText} nextButtonText={nextButtonText}
removeButtonText={removeButtonText} removeButtonText={removeButtonText}
nextButtonDisabled={nextButtonDisabled} nextButtonDisabled={nextButtonDisabled}
@@ -16,11 +16,14 @@ export interface CreateProps {
footerClassName?: string; footerClassName?: string;
showBackButton?: boolean; showBackButton?: boolean;
showNextButton?: boolean; showNextButton?: boolean;
showCancelButton?: boolean;
showRemoveButton?: boolean; showRemoveButton?: boolean;
onBack?: () => void; onBack?: () => void;
onNext?: () => void; onNext?: () => void;
onCancel?: () => void;
onRemove?: () => void; onRemove?: () => void;
backButtonText?: string; backButtonText?: string;
cancelButtonText?: string;
nextButtonText?: string; nextButtonText?: string;
removeButtonText?: string; removeButtonText?: string;
nextButtonDisabled?: boolean; nextButtonDisabled?: boolean;
@@ -61,11 +64,14 @@ export interface CreateViewProps {
footerClassName?: string; footerClassName?: string;
showBackButton: boolean; showBackButton: boolean;
showNextButton: boolean; showNextButton: boolean;
showCancelButton: boolean;
showRemoveButton: boolean; showRemoveButton: boolean;
onBack?: () => void; onBack?: () => void;
onNext?: () => void; onNext?: () => void;
onCancel?: () => void;
onRemove?: () => void; onRemove?: () => void;
backButtonText: string; backButtonText: string;
cancelButtonText?: string;
nextButtonText: string; nextButtonText: string;
removeButtonText?: string; removeButtonText?: string;
nextButtonDisabled: boolean; nextButtonDisabled: boolean;
@@ -17,11 +17,14 @@ export function CreateView({
footerClassName, footerClassName,
showBackButton, showBackButton,
showNextButton, showNextButton,
showCancelButton,
showRemoveButton, showRemoveButton,
onBack, onBack,
onNext, onNext,
onCancel,
onRemove, onRemove,
backButtonText, backButtonText,
cancelButtonText,
nextButtonText, nextButtonText,
removeButtonText, removeButtonText,
nextButtonDisabled, nextButtonDisabled,
@@ -79,11 +82,14 @@ export function CreateView({
<ModalFooter <ModalFooter
showBackButton={showBackButton} showBackButton={showBackButton}
showNextButton={showNextButton} showNextButton={showNextButton}
showCancelButton={showCancelButton}
showRemoveButton={showRemoveButton} showRemoveButton={showRemoveButton}
onBack={onBack} onBack={onBack}
onNext={onNext} onNext={onNext}
onCancel={onCancel}
onRemove={onRemove} onRemove={onRemove}
backButtonText={backButtonText} backButtonText={backButtonText}
cancelButtonText={cancelButtonText}
nextButtonText={nextButtonText} nextButtonText={nextButtonText}
removeButtonText={removeButtonText} removeButtonText={removeButtonText}
nextButtonDisabled={nextButtonDisabled} nextButtonDisabled={nextButtonDisabled}
@@ -13,6 +13,7 @@ import type { ModalFooterProps } from "./ModalFooter.types";
const ModalFooterContainer = memo<ModalFooterProps>((props) => { const ModalFooterContainer = memo<ModalFooterProps>((props) => {
const t = useTranslation("common"); const t = useTranslation("common");
const resolvedBackText = props.backButtonText ?? t("buttons.back"); const resolvedBackText = props.backButtonText ?? t("buttons.back");
const resolvedCancelText = props.cancelButtonText ?? t("buttons.cancel");
const resolvedNextText = props.nextButtonText ?? t("buttons.next"); const resolvedNextText = props.nextButtonText ?? t("buttons.next");
const resolvedRemoveText = props.removeButtonText ?? t("buttons.remove"); const resolvedRemoveText = props.removeButtonText ?? t("buttons.remove");
@@ -20,6 +21,7 @@ const ModalFooterContainer = memo<ModalFooterProps>((props) => {
<ModalFooterView <ModalFooterView
{...props} {...props}
backButtonText={resolvedBackText} backButtonText={resolvedBackText}
cancelButtonText={resolvedCancelText}
nextButtonText={resolvedNextText} nextButtonText={resolvedNextText}
removeButtonText={resolvedRemoveText} removeButtonText={resolvedRemoveText}
/> />
@@ -1,6 +1,10 @@
export interface ModalFooterProps { export interface ModalFooterProps {
showBackButton?: boolean; showBackButton?: boolean;
showNextButton?: boolean; showNextButton?: boolean;
/**
* Outline **Cancel** in the trailing group, left of Next (Overlay / Modal / Card).
*/
showCancelButton?: boolean;
/** /**
* Danger **Remove** in the left footer slot (selected create-flow modules). * Danger **Remove** in the left footer slot (selected create-flow modules).
* Takes the left slot instead of Back when both would otherwise show. * Takes the left slot instead of Back when both would otherwise show.
@@ -8,11 +12,16 @@ export interface ModalFooterProps {
showRemoveButton?: boolean; showRemoveButton?: boolean;
onBack?: () => void; onBack?: () => void;
onNext?: () => void; onNext?: () => void;
onCancel?: () => void;
onRemove?: () => void; onRemove?: () => void;
/** /**
* Custom back button text. If not provided, uses localized "Back" from common.json * Custom back button text. If not provided, uses localized "Back" from common.json
*/ */
backButtonText?: string; backButtonText?: string;
/**
* Custom cancel button text. If not provided, uses localized "Cancel" from common.json
*/
cancelButtonText?: string;
/** /**
* Custom next button text. If not provided, uses localized "Next" from common.json * Custom next button text. If not provided, uses localized "Next" from common.json
*/ */
@@ -7,11 +7,14 @@ import type { ModalFooterProps } from "./ModalFooter.types";
export function ModalFooterView({ export function ModalFooterView({
showBackButton = false, showBackButton = false,
showNextButton = false, showNextButton = false,
showCancelButton = false,
showRemoveButton = false, showRemoveButton = false,
onBack, onBack,
onNext, onNext,
onCancel,
onRemove, onRemove,
backButtonText, backButtonText,
cancelButtonText,
nextButtonText, nextButtonText,
removeButtonText, removeButtonText,
nextButtonDisabled = false, nextButtonDisabled = false,
@@ -26,6 +29,7 @@ export function ModalFooterView({
? stepperProp ? stepperProp
: currentStep !== undefined && totalSteps !== undefined; : currentStep !== undefined && totalSteps !== undefined;
const showStartBack = showBackButton && !showRemoveButton; const showStartBack = showBackButton && !showRemoveButton;
const showTrailing = showCancelButton || showNextButton;
return ( return (
<div <div
@@ -64,8 +68,19 @@ export function ModalFooterView({
</div> </div>
)} )}
{showNextButton && ( {showTrailing && (
<div className="absolute right-[16px] top-[12px]"> <div className="absolute right-[16px] top-[12px] flex items-center gap-[8px]">
{showCancelButton ? (
<Button
buttonType="outline"
palette="default"
size="medium"
onClick={onCancel}
>
{cancelButtonText}
</Button>
) : null}
{showNextButton ? (
<Button <Button
buttonType="filled" buttonType="filled"
palette="default" palette="default"
@@ -75,6 +90,7 @@ export function ModalFooterView({
> >
{nextButtonText} {nextButtonText}
</Button> </Button>
) : null}
</div> </div>
)} )}
+1
View File
@@ -6,6 +6,7 @@
"learnMore": "Learn more", "learnMore": "Learn more",
"askOrganizer": "Ask an organizer", "askOrganizer": "Ask an organizer",
"back": "Back", "back": "Back",
"cancel": "Cancel",
"next": "Next", "next": "Next",
"remove": "Remove", "remove": "Remove",
"finish": "Finish" "finish": "Finish"
+19 -7
View File
@@ -1,20 +1,32 @@
{ {
"header": { "header": {
"title": "Choose up to six core values", "title": "Choose up to six core values",
"descriptionLead": "What does your group hold most dear? You can also combine or", "descriptionLead": "Pick what your community holds most dear or ",
"addLink": "add", "addLink": "add your own",
"descriptionTrail": "new values to the list." "descriptionTrail": "."
}, },
"multiSelect": { "multiSelect": {
"addButtonText": "Add value", "addButtonText": "Add value",
"selectionCount": "{count} of {max}", "selectionCount": "{count} of {max} picked",
"limitReached": "Six of six values selected. Remaining values are unavailable until one is removed." "limitReached": "Six of six values selected. Remaining values are unavailable until one is removed."
}, },
"detailModal": { "detailModal": {
"subtitle": "Edit or add to this description to say what this value means to your group.", "subtitle": "Edit or add to this description to say what this value means to your group.",
"meaningLabel": "What does this value mean to your group?", "meaningLabel": "What does this value mean to your community?",
"signalsLabel": "Signals of Violation", "meaningDescription": "How does your community interpret the meaning of this value?",
"addValueButton": "Add Value", "meaningStepTitle": "What does {value} mean to your community?",
"signalsLabel": "Signals of violation",
"signalsDescription": "How do we know when we are not living up to this value?",
"signalsStepDescription": "How do we know when we are not living up to {value}?",
"stepBadge": "Step {n}",
"addToRule": "Add to Rule",
"deselect": "Deselect",
"cancel": "Cancel",
"save": "Save",
"placeholder": "Write a sentence or two.",
"generateDraftHint": "See our first draft for this value",
"filledHint": "These are your community's words. Change anything that does not fit.",
"addValueButton": "Add to Rule",
"customizeValueNameLabel": "Value name", "customizeValueNameLabel": "Value name",
"discardTitle": "Discard unsaved changes?", "discardTitle": "Discard unsaved changes?",
"discardPendingDescription": "This value will not be added.", "discardPendingDescription": "This value will not be added.",
+179 -87
View File
@@ -4,6 +4,11 @@ import "@testing-library/jest-dom/vitest";
import { renderWithProviders, dispatchBeforeUnload } from "../utils/test-utils"; import { renderWithProviders, dispatchBeforeUnload } from "../utils/test-utils";
import { CoreValuesSelectScreen } from "../../app/(app)/create/screens/select/CoreValuesSelectScreen"; import { CoreValuesSelectScreen } from "../../app/(app)/create/screens/select/CoreValuesSelectScreen";
const MEANING_STEP = "What does this value mean to your community?";
const SIGNALS_STEP = "Signals of violation";
const ADD_TO_RULE = "Add to Rule";
const ADD_OWN = "add your own";
describe("CoreValuesSelectScreen", () => { describe("CoreValuesSelectScreen", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -13,19 +18,50 @@ describe("CoreValuesSelectScreen", () => {
fireEvent.click(await screen.findByRole("button", { name: "Discard" })); fireEvent.click(await screen.findByRole("button", { name: "Discard" }));
} }
async function editMeaningInOpenDialog(next: string) { async function openValueDialog(label = "Accessibility") {
const dialog = await screen.findByRole("dialog"); fireEvent.click(screen.getByRole("button", { name: `Select ${label}` }));
const fields = within(dialog).getAllByRole("textbox"); return screen.findByRole("dialog");
fireEvent.change(fields[0], { target: { value: next } });
} }
it("opens core value detail modal when a preset chip is clicked", async () => { async function editMeaningInOpenDialog(next: string) {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: MEANING_STEP }));
fireEvent.change(within(dialog).getByRole("textbox"), {
target: { value: next },
});
}
async function confirmPendingValue(dialog: HTMLElement) {
fireEvent.click(within(dialog).getByRole("button", { name: ADD_TO_RULE }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
}
it("opens a card of meaning and signals rows when a preset chip is clicked", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
const dialog = await openValueDialog();
expect(within(dialog).getByText("Accessibility")).toBeInTheDocument();
expect( expect(
within(dialog).getByRole("button", { name: "Add Value" }), within(dialog).getByRole("button", { name: MEANING_STEP }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(
within(dialog).getByRole("button", { name: SIGNALS_STEP }),
).toBeInTheDocument();
expect(
within(dialog).getByText(
"How do we know when we are not living up to this value?",
),
).toBeInTheDocument();
expect(
within(dialog).getByRole("button", { name: ADD_TO_RULE }),
).toBeInTheDocument();
expect(
within(dialog).getByRole("button", { name: "Cancel" }),
).toBeInTheDocument();
expect(
within(dialog).queryByRole("button", { name: "Deselect" }),
).not.toBeInTheDocument();
expect( expect(
within(dialog).queryByRole("button", { name: "Remove" }), within(dialog).queryByRole("button", { name: "Remove" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@@ -48,23 +84,73 @@ describe("CoreValuesSelectScreen", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("starts the value modal empty and Generate draft inserts catalog copy", async () => { it("offers add your own in the header and a of-6 counter", () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByRole("button", { name: "Select Accessibility" })); expect(
const dialog = await screen.findByRole("dialog"); screen.getByRole("heading", { name: "Choose up to six core values" }),
const fields = within(dialog).getAllByRole("textbox") as HTMLTextAreaElement[]; ).toBeInTheDocument();
expect(fields[0].value).toBe(""); expect(screen.getByRole("button", { name: ADD_OWN })).toBeInTheDocument();
expect(fields[1].value).toBe(""); expect(screen.getByText("0 of 6 picked")).toBeInTheDocument();
});
it("starts a meaning step empty and Generate draft inserts catalog copy", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
const dialog = await openValueDialog();
expect(within(dialog).queryByRole("textbox")).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: MEANING_STEP }));
const field = within(dialog).getByRole("textbox") as HTMLTextAreaElement;
expect(field.value).toBe("");
expect(field).toHaveAttribute("placeholder", "Write a sentence or two.");
expect(
within(dialog).getByText("See our first draft for this value"),
).toBeInTheDocument();
fireEvent.click( fireEvent.click(
within(dialog).getAllByRole("button", { name: "Generate draft" })[0], within(dialog).getByRole("button", { name: "Generate draft" }),
); );
expect(fields[0].value).toMatch(/usable by everyone/i); expect(field.value).toMatch(/usable by everyone/i);
expect(
within(dialog).queryByRole("button", { name: "Generate draft" }),
).not.toBeInTheDocument();
expect(
within(dialog).getByText(
"These are your community's words. Change anything that does not fit.",
),
).toBeInTheDocument();
});
it("opens a signals step with Generate draft on an empty field", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
const dialog = await openValueDialog();
fireEvent.click(within(dialog).getByRole("button", { name: SIGNALS_STEP }));
expect(within(dialog).getByRole("textbox")).toHaveValue("");
expect(
within(dialog).getByRole("button", { name: "Generate draft" }),
).toBeInTheDocument();
expect(
within(dialog).getByText(
"How do we know when we are not living up to Accessibility?",
),
).toBeInTheDocument();
});
it("returns from a step to the card when Cancel is clicked", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
const dialog = await openValueDialog();
fireEvent.click(within(dialog).getByRole("button", { name: MEANING_STEP }));
expect(within(dialog).getByRole("textbox")).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
expect(within(dialog).queryByRole("textbox")).not.toBeInTheDocument();
expect(
within(dialog).getByRole("button", { name: MEANING_STEP }),
).toBeInTheDocument();
expect(
within(dialog).getByRole("button", { name: ADD_TO_RULE }),
).toBeInTheDocument();
}); });
it("closes a pending value without confirm when fields are unchanged", async () => { it("closes a pending value without confirm when fields are unchanged", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility")); await openValueDialog();
await screen.findByRole("dialog");
fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@@ -72,6 +158,9 @@ describe("CoreValuesSelectScreen", () => {
expect( expect(
screen.queryByRole("button", { name: "Keep editing" }), screen.queryByRole("button", { name: "Keep editing" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Select Accessibility" }),
).toBeInTheDocument();
}); });
it("asks to discard when closing a pending value after an edit", async () => { it("asks to discard when closing a pending value after an edit", async () => {
@@ -79,6 +168,7 @@ describe("CoreValuesSelectScreen", () => {
fireEvent.click(screen.getByText("Accessibility")); fireEvent.click(screen.getByText("Accessibility"));
await editMeaningInOpenDialog("Changed meaning"); await editMeaningInOpenDialog("Changed meaning");
fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "Escape" });
fireEvent.keyDown(document, { key: "Escape" });
expect( expect(
await screen.findByRole("button", { name: "Keep editing" }), await screen.findByRole("button", { name: "Keep editing" }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -90,8 +180,7 @@ describe("CoreValuesSelectScreen", () => {
it("does not block tab close when a pending value is unchanged", async () => { it("does not block tab close when a pending value is unchanged", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility")); await openValueDialog();
await screen.findByRole("dialog");
expect(dispatchBeforeUnload()).toBe(false); expect(dispatchBeforeUnload()).toBe(false);
}); });
@@ -107,53 +196,55 @@ describe("CoreValuesSelectScreen", () => {
fireEvent.click(screen.getByText("Accessibility")); fireEvent.click(screen.getByText("Accessibility"));
await editMeaningInOpenDialog("Changed meaning"); await editMeaningInOpenDialog("Changed meaning");
fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "Escape" });
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.click(await screen.findByRole("button", { name: "Keep editing" })); fireEvent.click(await screen.findByRole("button", { name: "Keep editing" }));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
expect( expect(
within(dialog).getByRole("button", { name: "Add Value" }), within(dialog).getByRole("button", { name: ADD_TO_RULE }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("saves details when Add Value is clicked", async () => { it("keeps a value with empty meaning and signals when Add to Rule is clicked", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility")); const dialog = await openValueDialog();
const dialog = await screen.findByRole("dialog"); await confirmPendingValue(dialog);
fireEvent.click( expect(
within(dialog).getByRole("button", { name: "Add Value" }), screen.getByRole("button", { name: "Deselect Accessibility" }),
); ).toBeInTheDocument();
await waitFor(() => { expect(screen.getByText("1 of 6 picked")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
}); });
it("opens meaning and signals fields editable without Customize", async () => { it("opens meaning and signals steps editable without Customize", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility")); const dialog = await openValueDialog();
const dialog = await screen.findByRole("dialog"); fireEvent.click(within(dialog).getByRole("button", { name: MEANING_STEP }));
const fields = within(dialog).getAllByRole("textbox"); expect(within(dialog).getByRole("textbox")).toBeEnabled();
expect(fields.length).toBeGreaterThanOrEqual(2); fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
for (const field of fields) { fireEvent.click(within(dialog).getByRole("button", { name: SIGNALS_STEP }));
expect(field).toBeEnabled(); expect(within(dialog).getByRole("textbox")).toBeEnabled();
}
}); });
it("shows Remove only after the value has been added", async () => { it("shows Deselect after the value has been added, not Remove in the kebab", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility")); const pending = await openValueDialog();
const pending = await screen.findByRole("dialog"); await confirmPendingValue(pending);
fireEvent.click(within(pending).getByRole("button", { name: "Add Value" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
fireEvent.click(screen.getByText("Accessibility")); fireEvent.click(screen.getByText("Accessibility"));
const editing = await screen.findByRole("dialog"); const editing = await screen.findByRole("dialog");
expect( expect(
within(editing).getByRole("button", { name: "Remove" }), within(editing).getByRole("button", { name: "Deselect" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(
within(editing).getByRole("button", { name: "Save" }),
).toBeInTheDocument();
expect(
within(editing).queryByRole("button", { name: "Remove" }),
).not.toBeInTheDocument();
fireEvent.click( fireEvent.click(
within(editing).getByRole("button", { name: "More options" }), within(editing).getByRole("button", { name: "More options" }),
); );
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument(); expect(
screen.queryByRole("menuitem", { name: "Remove" }),
).not.toBeInTheDocument();
expect( expect(
screen.getByRole("menuitem", { name: "Customize" }), screen.getByRole("menuitem", { name: "Customize" }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -162,10 +253,25 @@ describe("CoreValuesSelectScreen", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("Deselect takes a confirmed value out of the selection", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
const pending = await openValueDialog();
await confirmPendingValue(pending);
fireEvent.click(screen.getByText("Accessibility"));
const editing = await screen.findByRole("dialog");
fireEvent.click(within(editing).getByRole("button", { name: "Deselect" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(
screen.getByRole("button", { name: "Select Accessibility" }),
).toBeInTheDocument();
expect(screen.getByText("0 of 6 picked")).toBeInTheDocument();
});
it("Customize walks name and description before policy details", async () => { it("Customize walks name and description before policy details", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility")); const dialog = await openValueDialog();
const dialog = await screen.findByRole("dialog");
fireEvent.click( fireEvent.click(
within(dialog).getByRole("button", { name: "More options" }), within(dialog).getByRole("button", { name: "More options" }),
); );
@@ -176,13 +282,14 @@ describe("CoreValuesSelectScreen", () => {
); );
expect(screen.queryByText("Custom policy details")).not.toBeInTheDocument(); expect(screen.queryByText("Custom policy details")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Next" })); fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByPlaceholderText("Policy description")).toHaveValue("");
fireEvent.click(screen.getByRole("button", { name: "Next" })); fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect( expect(
await screen.findByText("Custom policy details"), await screen.findByText("Custom policy details"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByRole("button", { screen.getByRole("button", {
name: "What does this value mean to your group?", name: MEANING_STEP,
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -190,15 +297,9 @@ describe("CoreValuesSelectScreen", () => {
it("Customize shows meaning saved from the value modal", async () => { it("Customize shows meaning saved from the value modal", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility")); fireEvent.click(screen.getByText("Accessibility"));
const pending = await screen.findByRole("dialog"); await editMeaningInOpenDialog("Edited meaning from value modal");
const fields = within(pending).getAllByRole("textbox"); const pending = screen.getByRole("dialog");
fireEvent.change(fields[0], { await confirmPendingValue(pending);
target: { value: "Edited meaning from value modal" },
});
fireEvent.click(within(pending).getByRole("button", { name: "Add Value" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
fireEvent.click(screen.getByText("Accessibility")); fireEvent.click(screen.getByText("Accessibility"));
const editing = await screen.findByRole("dialog"); const editing = await screen.findByRole("dialog");
fireEvent.click( fireEvent.click(
@@ -207,10 +308,11 @@ describe("CoreValuesSelectScreen", () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
expect(await screen.findByPlaceholderText("Policy name")).toBeInTheDocument(); expect(await screen.findByPlaceholderText("Policy name")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Next" })); fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByPlaceholderText("Policy description")).toHaveValue("");
fireEvent.click(screen.getByRole("button", { name: "Next" })); fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click( fireEvent.click(
await screen.findByRole("button", { await screen.findByRole("button", {
name: "What does this value mean to your group?", name: MEANING_STEP,
}), }),
); );
expect( expect(
@@ -220,8 +322,7 @@ describe("CoreValuesSelectScreen", () => {
it("closing Customize without Finalize returns to the value modal", async () => { it("closing Customize without Finalize returns to the value modal", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility")); const dialog = await openValueDialog();
const dialog = await screen.findByRole("dialog");
fireEvent.click( fireEvent.click(
within(dialog).getByRole("button", { name: "More options" }), within(dialog).getByRole("button", { name: "More options" }),
); );
@@ -237,14 +338,13 @@ describe("CoreValuesSelectScreen", () => {
}); });
const valueDialog = screen.getByRole("dialog"); const valueDialog = screen.getByRole("dialog");
expect( expect(
within(valueDialog).getByRole("button", { name: "Add Value" }), within(valueDialog).getByRole("button", { name: ADD_TO_RULE }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("Customize Finalize shows reordered fields in the value modal", async () => { it("Customize Finalize shows reordered fields in the value modal", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility")); const dialog = await openValueDialog();
const dialog = await screen.findByRole("dialog");
fireEvent.click( fireEvent.click(
within(dialog).getByRole("button", { name: "More options" }), within(dialog).getByRole("button", { name: "More options" }),
); );
@@ -292,21 +392,13 @@ describe("CoreValuesSelectScreen", () => {
? (document.getElementById(labelledby)?.textContent ?? "").trim() ? (document.getElementById(labelledby)?.textContent ?? "").trim()
: ""; : "";
}); });
expect(labels[0]).toMatch(/Signals of Violation/); expect(labels[0]).toMatch(/Signals of violation/i);
expect(labels[1]).toMatch(/What does this value mean to your group/); expect(labels[1]).toMatch(/What does this value mean to your community/);
}); });
// The "Add value" control opens an empty custom-policy wizard. Dismissing describe("add your own wizard", () => {
// without Finalize leaves the chip list unchanged. Finalize writes a
// selected chip named from the wizard title.
describe("Add value wizard", () => {
const CUSTOM_LABEL = "ZZTopBespokeValue"; const CUSTOM_LABEL = "ZZTopBespokeValue";
/**
* The label can also appear in the modal header while the modal
* is open, and as the chip's "Remove" button aria-label. Scope to
* chip-row buttons by excluding both.
*/
function countCustomChips(label: string) { function countCustomChips(label: string) {
return screen return screen
.queryAllByRole("button", { .queryAllByRole("button", {
@@ -316,12 +408,13 @@ describe("CoreValuesSelectScreen", () => {
} }
async function completeAddValueWizard(label: string) { async function completeAddValueWizard(label: string) {
fireEvent.click(screen.getByRole("button", { name: "Add value" })); fireEvent.click(screen.getByRole("button", { name: ADD_OWN }));
const nameInput = await screen.findByPlaceholderText("Policy name"); const nameInput = await screen.findByPlaceholderText("Policy name");
fireEvent.change(nameInput, { target: { value: label } }); fireEvent.change(nameInput, { target: { value: label } });
fireEvent.click(screen.getByRole("button", { name: "Next" })); fireEvent.click(screen.getByRole("button", { name: "Next" }));
const descriptionInput = const descriptionInput =
await screen.findByPlaceholderText("Policy description"); await screen.findByPlaceholderText("Policy description");
expect(descriptionInput).toHaveValue("");
fireEvent.change(descriptionInput, { fireEvent.change(descriptionInput, {
target: { value: "A community-authored value." }, target: { value: "A community-authored value." },
}); });
@@ -337,7 +430,7 @@ describe("CoreValuesSelectScreen", () => {
it("collapses the field-type picker after adding a custom field", async () => { it("collapses the field-type picker after adding a custom field", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByRole("button", { name: "Add value" })); fireEvent.click(screen.getByRole("button", { name: ADD_OWN }));
fireEvent.change(await screen.findByPlaceholderText("Policy name"), { fireEvent.change(await screen.findByPlaceholderText("Policy name"), {
target: { value: CUSTOM_LABEL }, target: { value: CUSTOM_LABEL },
}); });
@@ -373,18 +466,18 @@ describe("CoreValuesSelectScreen", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("opens the empty custom-policy wizard from Add value", async () => { it("opens the empty custom-policy wizard from add your own", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByRole("button", { name: "Add value" })); fireEvent.click(screen.getByRole("button", { name: ADD_OWN }));
expect( expect(
await screen.findByPlaceholderText("Policy name"), await screen.findByPlaceholderText("Policy name"),
).toHaveValue(""); ).toHaveValue("");
expect(screen.queryByPlaceholderText("Type to add")).not.toBeInTheDocument(); expect(screen.queryByPlaceholderText("Type to add")).not.toBeInTheDocument();
}); });
it("leaves no chip when the Add value wizard is dismissed", async () => { it("leaves no chip when the add your own wizard is dismissed", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByRole("button", { name: "Add value" })); fireEvent.click(screen.getByRole("button", { name: ADD_OWN }));
await screen.findByPlaceholderText("Policy name"); await screen.findByPlaceholderText("Policy name");
fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => { await waitFor(() => {
@@ -412,7 +505,7 @@ describe("CoreValuesSelectScreen", () => {
async function addPresetValue(label: string) { async function addPresetValue(label: string) {
fireEvent.click(screen.getByText(label)); fireEvent.click(screen.getByText(label));
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Add Value" })); fireEvent.click(within(dialog).getByRole("button", { name: ADD_TO_RULE }));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
}); });
@@ -420,7 +513,7 @@ describe("CoreValuesSelectScreen", () => {
it("shows a counter, disables remaining chips, and announces the limit", async () => { it("shows a counter, disables remaining chips, and announces the limit", async () => {
renderWithProviders(<CoreValuesSelectScreen />); renderWithProviders(<CoreValuesSelectScreen />);
expect(screen.getByText("0 of 6")).toBeInTheDocument(); expect(screen.getByText("0 of 6 picked")).toBeInTheDocument();
await addPresetValue("Accessibility"); await addPresetValue("Accessibility");
await addPresetValue("Accountability"); await addPresetValue("Accountability");
@@ -429,7 +522,7 @@ describe("CoreValuesSelectScreen", () => {
await addPresetValue("Anti-oppression"); await addPresetValue("Anti-oppression");
await addPresetValue("Autonomy"); await addPresetValue("Autonomy");
expect(screen.getByText("6 of 6")).toBeInTheDocument(); expect(screen.getByText("6 of 6 picked")).toBeInTheDocument();
expect( expect(
screen.getByText( screen.getByText(
"Six of six values selected. Remaining values are unavailable until one is removed.", "Six of six values selected. Remaining values are unavailable until one is removed.",
@@ -438,8 +531,7 @@ describe("CoreValuesSelectScreen", () => {
expect( expect(
screen.getByRole("button", { name: "Select Capacity building" }), screen.getByRole("button", { name: "Select Capacity building" }),
).toBeDisabled(); ).toBeDisabled();
expect(screen.getByRole("button", { name: "Add value" })).toBeDisabled(); expect(screen.getByRole("button", { name: ADD_OWN })).toBeDisabled();
expect(screen.getByRole("button", { name: /^add$/i })).toBeDisabled();
}); });
}); });
}); });
+20
View File
@@ -122,6 +122,26 @@ describe("Create", () => {
expect(onBack).toHaveBeenCalledTimes(1); expect(onBack).toHaveBeenCalledTimes(1);
}); });
it("calls onCancel when the trailing Cancel button is clicked", () => {
const onCancel = vi.fn();
renderWithProviders(
<Create
{...defaultProps}
showBackButton={false}
showCancelButton
showNextButton
onCancel={onCancel}
cancelButtonText="Cancel"
nextButtonText="Add to Rule"
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(
screen.getByRole("button", { name: "Add to Rule" }),
).toBeInTheDocument();
});
it("calls onNext when next button is clicked", () => { it("calls onNext when next button is clicked", () => {
const onNext = vi.fn(); const onNext = vi.fn();
renderWithProviders( renderWithProviders(
+215 -2
View File
@@ -10,6 +10,7 @@ import {
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen"; import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext"; import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
import { clearCoreValueDetailsLocalStorage } from "../../app/(app)/create/utils/coreValueDetailsLocalStorage";
import type { CreateFlowState } from "../../app/(app)/create/types"; import type { CreateFlowState } from "../../app/(app)/create/types";
function catalogChipName(label: string) { function catalogChipName(label: string) {
@@ -358,6 +359,176 @@ describe("FinalReviewScreen — chip detail modal", () => {
expect( expect(
screen.getByRole("menuitem", { name: /^customize$/i }), screen.getByRole("menuitem", { name: /^customize$/i }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(
screen.queryByRole("menuitem", { name: /^remove$/i }),
).not.toBeInTheDocument();
});
it("offers Duplicate with five values picked and hides it at six", async () => {
function CoreValuesHarness({ count }: { count: number }) {
const { replaceState } = useCreateFlow();
useLayoutEffect(() => {
const ids = Array.from({ length: count }, (_, i) => String(i + 1));
replaceState({
selectedCoreValueIds: ids,
coreValuesChipsSnapshot: ids.map((id, i) => ({
id,
label: `Value ${i + 1}`,
state: "selected" as const,
})),
});
}, [count, replaceState]);
return <FinalReviewScreen />;
}
const { unmount } = render(<CoreValuesHarness count={5} />);
fireEvent.click(
await screen.findByRole("button", { name: catalogChipName("Value 1") }),
);
fireEvent.click(
within(await screen.findByRole("dialog")).getByRole("button", {
name: /more options/i,
}),
);
expect(
await screen.findByRole("menuitem", { name: /^duplicate$/i }),
).toBeInTheDocument();
unmount();
render(<CoreValuesHarness count={6} />);
fireEvent.click(
await screen.findByRole("button", { name: catalogChipName("Value 1") }),
);
fireEvent.click(
within(await screen.findByRole("dialog")).getByRole("button", {
name: /more options/i,
}),
);
await waitFor(() => {
expect(
screen.getByRole("menuitem", { name: /^customize$/i }),
).toBeInTheDocument();
});
expect(
screen.queryByRole("menuitem", { name: /^duplicate$/i }),
).not.toBeInTheDocument();
});
it("duplicates a fifth value into the remaining slot", async () => {
let latest: CreateFlowState = {};
render(
<FinalReviewWithStateProbe
onState={(s) => {
latest = s;
}}
initial={{
selectedCoreValueIds: ["1", "2", "3", "4", "5"],
coreValuesChipsSnapshot: ["1", "2", "3", "4", "5"].map((id, i) => ({
id,
label: `Value ${i + 1}`,
state: "selected" as const,
})),
}}
/>,
);
fireEvent.click(
await screen.findByRole("button", { name: catalogChipName("Value 1") }),
);
fireEvent.click(
within(await screen.findByRole("dialog")).getByRole("button", {
name: /more options/i,
}),
);
fireEvent.click(
await screen.findByRole("menuitem", { name: /^duplicate$/i }),
);
await waitFor(() => {
expect(latest.selectedCoreValueIds).toHaveLength(6);
});
expect(latest.coreValuesChipsSnapshot?.map((row) => row.label)).toContain(
"Value 1 (copy)",
);
});
it("saves in-modal edits to extra value field blocks", async () => {
let latest: CreateFlowState = {};
let view: ReturnType<typeof render> | undefined;
try {
view = render(
<FinalReviewWithStateProbe
onState={(s) => {
latest = s;
}}
initial={{
selectedCoreValueIds: ["1"],
coreValuesChipsSnapshot: [
{ id: "1", label: "Accessibility", state: "selected" },
],
coreValueDetailsByChipId: {
"1": {
meaning: "Everyone can participate.",
signals: "Captions and ramps.",
},
},
customMethodCardFieldBlocksById: {
"1": [
{
kind: "text",
id: "extra-note",
blockTitle: "House note",
placeholderText: "Be kind.",
},
{
kind: "text",
id: "facet-meaning",
blockTitle: "What this value means",
placeholderText: "Everyone can participate.",
},
{
kind: "text",
id: "facet-signals",
blockTitle: "Signals",
placeholderText: "Captions and ramps.",
},
],
},
}}
/>,
);
fireEvent.click(
await screen.findByRole("button", {
name: catalogChipName("Accessibility"),
}),
);
const dialog = await screen.findByRole("dialog");
fireEvent.change(
within(dialog).getByRole("textbox", { name: "House note" }),
{ target: { value: "Leave the ramp clear." } },
);
const save = within(dialog).getByRole("button", { name: "Save" });
await waitFor(() => {
expect(save).toBeEnabled();
});
fireEvent.click(save);
await waitFor(() => {
expect(
latest.customMethodCardFieldBlocksById?.["1"]?.map(
(block) => block.id,
),
).toEqual(["extra-note", "facet-meaning", "facet-signals"]);
});
const extra = latest.customMethodCardFieldBlocksById?.["1"]?.find(
(block) => block.id === "extra-note",
);
expect(extra?.kind === "text" ? extra.placeholderText : "").toBe(
"Leave the ramp clear.",
);
} finally {
view?.unmount();
clearCoreValueDetailsLocalStorage();
}
}); });
it("values Customize starts on the policy name step", async () => { it("values Customize starts on the policy name step", async () => {
@@ -388,6 +559,8 @@ describe("FinalReviewScreen — chip detail modal", () => {
await screen.findByPlaceholderText("Policy name"), await screen.findByPlaceholderText("Policy name"),
).toHaveValue("Accessibility"); ).toHaveValue("Accessibility");
expect(screen.queryByText("Custom policy details")).not.toBeInTheDocument(); expect(screen.queryByText("Custom policy details")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByPlaceholderText("Policy description")).toHaveValue("");
}); });
it("opens method chip modal editable, with Save disabled until a field changes", async () => { it("opens method chip modal editable, with Save disabled until a field changes", async () => {
@@ -432,14 +605,48 @@ describe("FinalReviewScreen — chip detail modal", () => {
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByText(/what does this value mean to your group\?/i), screen.getByRole("button", {
name: /what does this value mean to your community\?/i,
}),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
expect( expect(
screen.getByText(/signals of violation/i), screen.getByRole("button", { name: /signals of violation/i }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("starts a values meaning step empty and Generate draft inserts catalog copy", async () => {
function CoreValuesHarness() {
const { replaceState } = useCreateFlow();
useLayoutEffect(() => {
replaceState({
selectedCoreValueIds: ["1"],
coreValuesChipsSnapshot: [
{ id: "1", label: "Accessibility", state: "selected" },
],
});
}, [replaceState]);
return <FinalReviewScreen />;
}
render(<CoreValuesHarness />);
fireEvent.click(
await screen.findByRole("button", { name: catalogChipName("Accessibility") }),
);
const dialog = await screen.findByRole("dialog");
fireEvent.click(
within(dialog).getByRole("button", {
name: /what does this value mean to your community\?/i,
}),
);
const field = within(dialog).getByRole("textbox") as HTMLTextAreaElement;
expect(field.value).toBe("");
fireEvent.click(
within(dialog).getByRole("button", { name: "Generate draft" }),
);
expect(field.value).toMatch(/usable by everyone/i);
});
it("opens the editable Save modal for a values chip", async () => { it("opens the editable Save modal for a values chip", async () => {
// Plain custom-rule path: snapshot is set, sections is not. // Plain custom-rule path: snapshot is set, sections is not.
function CoreValuesHarness() { function CoreValuesHarness() {
@@ -463,6 +670,12 @@ describe("FinalReviewScreen — chip detail modal", () => {
expect( expect(
within(dialog).getByRole("button", { name: "Save" }), within(dialog).getByRole("button", { name: "Save" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(
within(dialog).getByRole("button", { name: "Deselect" }),
).toBeInTheDocument();
expect(
within(dialog).getByRole("button", { name: "Cancel" }),
).toBeInTheDocument();
expect(within(dialog).getAllByRole("button", { name: "Close" })).toHaveLength( expect(within(dialog).getAllByRole("button", { name: "Close" })).toHaveLength(
1, 1,
); );
+26 -1
View File
@@ -1,4 +1,7 @@
import { describe, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils";
import { import {
componentTestSuite, componentTestSuite,
type ComponentTestSuiteConfig, type ComponentTestSuiteConfig,
@@ -27,4 +30,26 @@ const config: ComponentTestSuiteConfig<Props> = {
describe("ModalFooter", () => { describe("ModalFooter", () => {
componentTestSuite<Props>(config); componentTestSuite<Props>(config);
it("renders Cancel left of Next in the trailing group", () => {
const onCancel = vi.fn();
const onNext = vi.fn();
renderWithProviders(
<ModalFooter
showBackButton={false}
showCancelButton
showNextButton
onCancel={onCancel}
onNext={onNext}
cancelButtonText="Cancel"
nextButtonText="Add to Rule"
stepper={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onNext).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Add to Rule" }));
expect(onNext).toHaveBeenCalledTimes(1);
});
}); });
@@ -135,6 +135,29 @@ describe("ModalTextAreaField behavior", () => {
expect(onChange).toHaveBeenCalledWith("Catalog first draft"); expect(onChange).toHaveBeenCalledWith("Catalog first draft");
}); });
it("hides Generate draft on a filled field and shows the filled hint", () => {
renderWithProviders(
<ModalTextAreaField
label="Core principle"
value="Community words"
onChange={() => {}}
generateDraftText="Catalog first draft"
generateDraftLabel="Generate draft"
generateDraftHint="See our first draft for this value"
filledHint="These are your community's words. Change anything that does not fit."
/>,
);
expect(
screen.queryByRole("button", { name: "Generate draft" }),
).not.toBeInTheDocument();
expect(
screen.getByText(
"These are your community's words. Change anything that does not fit.",
),
).toBeInTheDocument();
});
it("hides Generate draft when there is no catalog copy", () => { it("hides Generate draft when there is no catalog copy", () => {
renderWithProviders( renderWithProviders(
<ModalTextAreaField <ModalTextAreaField
+12
View File
@@ -189,6 +189,18 @@ describe("MultiSelect – behaviour specifics", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("renders a count-only caption when formHeader is off", () => {
render(
<MultiSelect
options={defaultChipOptions}
formHeader={false}
addButton={false}
selectionCountText="1 of 6 picked"
/>,
);
expect(screen.getByText("1 of 6 picked")).toBeInTheDocument();
});
it("handles custom chip confirm", async () => { it("handles custom chip confirm", async () => {
const handleConfirm = vi.fn(); const handleConfirm = vi.fn();
const customOptions = [ const customOptions = [
+4
View File
@@ -36,4 +36,8 @@ describe("create footer messages", () => {
expect(messages.create.footer.skipForNow).toBe("Skip for now"); expect(messages.create.footer.skipForNow).toBe("Skip for now");
expect(messages.create.footer.submitEmail).toBe("Submit email"); expect(messages.create.footer.submitEmail).toBe("Submit email");
}); });
it("exposes Confirm values for the core-values footer CTA", () => {
expect(messages.create.footer.confirmCoreValues).toBe("Confirm values");
});
}); });