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 877 additions and 354 deletions
Showing only changes of commit 64a542f456 - Show all commits

No files matched your search

+53 -28
View File
@@ -1065,35 +1065,60 @@ function CreateFlowLayoutContent({
</div>
) : showCustomRuleFooterConfirm &&
customRuleConfirmFooter ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
customRuleConfirmFooter.selectionIds(state).length === 0
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
const cf = customRuleConfirmFooter;
const facet = methodCardFacetSectionForConfirmStep(cf.step);
if (facet != null && cf.selectionIds(state).length > 0) {
setMethodSectionsPinCommitted(facet, true);
<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
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
customRuleConfirmFooter.selectionIds(state).length === 0
}
if (reviewReturnTarget) {
router.push(
createFlowStepPathAfterStrippingReviewReturn(
reviewReturnTarget,
searchParams,
),
);
return;
}
goToNextStep();
}}
>
{footer[customRuleConfirmFooter.footerMessageKey]}
</Button>
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
const cf = customRuleConfirmFooter;
const facet = methodCardFacetSectionForConfirmStep(cf.step);
if (facet != null && cf.selectionIds(state).length > 0) {
setMethodSectionsPinCommitted(facet, true);
}
if (reviewReturnTarget) {
router.push(
createFlowStepPathAfterStrippingReviewReturn(
reviewReturnTarget,
searchParams,
),
);
return;
}
goToNextStep();
}}
>
{footer[customRuleConfirmFooter.footerMessageKey]}
</Button>
</div>
) : isConfirmStakeholdersManagePublished ? (
<Button
buttonType="filled"
@@ -244,7 +244,6 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
titleTrim.length > 0 &&
titleTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
const descriptionOk =
descriptionTrim.length > 0 &&
descriptionTrim.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS;
if (wizardStep === 1) return titleOk;
if (wizardStep === 2) return descriptionOk;
@@ -1,10 +1,9 @@
"use client";
/**
* Final-review chip modal: **Core values** and **method** facets share the
* kebab → **Duplicate** (values only when under the cap) / **Remove** pattern
* from the create-card facet modals (`Create` +
* {@link buildCustomRuleModalKebabMenu}), plus a footer **Remove** when the
* Final-review chip modal: **Core values** use {@link ValueDialog} (Deselect +
* Cancel + Save; kebab Customize / Duplicate under the cap). **Method** facets
* keep `Create` with kebab Duplicate/Remove plus a footer **Remove** when the
* chip is already in the selection. Values and method chips also offer
* **Customize**, which opens {@link CustomMethodCardWizard} prefilled from the
* chip. Fields are editable on open; Save persists body edits without renaming.
@@ -22,10 +21,10 @@ import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
import {
CommunicationMethodEditFields,
ConflictManagementEditFields,
CoreValueEditFields,
DecisionApproachEditFields,
MembershipMethodEditFields,
} from "./methodEditFields";
import { ValueDialog } from "./ValueDialog";
import CustomMethodCardModalBody from "./CustomMethodCardModalBody";
import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu";
import { useDiscardCustomizeConfirm } from "../hooks/useDiscardCustomizeConfirm";
@@ -34,6 +33,7 @@ import {
communicationPresetFor,
conflictManagementPresetFor,
coreValuePresetFor,
coreValuePresetForLabel,
decisionApproachPresetFor,
emptyCommunicationDetails,
emptyConflictManagementDetails,
@@ -485,8 +485,7 @@ export function FinalReviewChipEditModal({
buildMethodCardWizardInitialValues({
cardId: target.overrideKey,
fallbackTitle: target.chipLabel,
fallbackDescription:
value.supportText?.trim() || tCv.detailModal.subtitle,
fallbackDescription: value.supportText?.trim() || "",
meta: {},
persistedBlocks: state.customMethodCardFieldBlocksById,
draftFieldBlocks: null,
@@ -559,7 +558,6 @@ export function FinalReviewChipEditModal({
tComm.sectionHeadings,
tCv.detailModal.meaningLabel,
tCv.detailModal.signalsLabel,
tCv.detailModal.subtitle,
tDa.sectionHeadings,
tMem.sectionHeadings,
target,
@@ -730,8 +728,6 @@ export function FinalReviewChipEditModal({
(state.selectedCoreValueIds ?? []).length >= 5
? undefined
: handleDuplicateCoreValue,
showRemove: true,
onRemove: handleRemoveCoreValueFromModal,
});
}
if (!isMethodFacetGroup(target.groupKey)) return [];
@@ -744,7 +740,6 @@ export function FinalReviewChipEditModal({
}, [
handleCustomize,
handleDuplicateCoreValue,
handleRemoveCoreValueFromModal,
handleRemoveSelectedFromModal,
isChipInSelection,
modalKebabMenu,
@@ -1080,12 +1075,61 @@ export function FinalReviewChipEditModal({
const showFooterRemove =
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 (
<>
<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
isOpen={isOpen && !addCustomWizardOpen}
isOpen={
isOpen && !addCustomWizardOpen && !isCoreValuesTarget
}
onClose={handleModalClose}
onBack={handleModalClose}
showBackButton={!showFooterRemove}
@@ -1113,29 +1157,6 @@ export function FinalReviewChipEditModal({
ariaLabel={target?.chipLabel || "Edit chip details"}
>
<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" &&
target &&
(modalUsesWizardFieldBlocksBody ? (
@@ -1451,3 +1472,12 @@ function findMethodSupportText(
}
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;
/** Show the help "?" icon next to the label. Off until tooltip behavior ships. */
helpIcon?: boolean;
/** Hide the label when the modal title already asks the question. */
hideLabel?: boolean;
/** Current text value. */
value: string;
/** Fired on every change with the new value (no event). */
@@ -35,6 +37,12 @@ export interface ModalTextAreaFieldProps {
generateDraftLabel?: string;
/** Hint beside Generate draft. */
generateDraftHint?: string;
/**
* Shown under a filled field instead of Generate draft (picked value
* field view).
*/
filledHint?: string;
autoFocus?: boolean;
className?: string;
}
@@ -63,6 +71,7 @@ function PlusGlyph() {
function ModalTextAreaFieldComponent({
label,
helpIcon = false,
hideLabel = false,
value,
onChange,
rows = 4,
@@ -71,26 +80,34 @@ function ModalTextAreaFieldComponent({
generateDraftText,
generateDraftLabel,
generateDraftHint,
filledHint,
autoFocus = false,
className = "",
}: ModalTextAreaFieldProps) {
const labelId = useId();
const draftSource = generateDraftText?.trim() ?? "";
const filled = value.trim().length > 0;
const showFilledHint = filled && Boolean(filledHint) && !disabled;
const showGenerateDraft =
draftSource.length > 0 && Boolean(generateDraftLabel);
const compose = showGenerateDraft;
!showFilledHint &&
draftSource.length > 0 &&
Boolean(generateDraftLabel);
const compose = showGenerateDraft || showFilledHint || hideLabel;
return (
<div
className={`flex flex-col ${compose ? "gap-[12px]" : "gap-2"} ${className}`.trim()}
>
<div id={labelId}>
<InputLabel
label={label}
helpIcon={helpIcon}
size="s"
palette="default"
/>
</div>
{hideLabel ? null : (
<div id={labelId}>
<InputLabel
label={label}
helpIcon={helpIcon}
size="s"
palette="default"
/>
</div>
)}
<TextArea
formHeader={false}
value={value}
@@ -100,7 +117,9 @@ function ModalTextAreaFieldComponent({
appearance={compose ? "compose" : "embedded"}
placeholder={placeholder}
disabled={disabled}
aria-labelledby={labelId}
autoFocus={autoFocus}
aria-label={hideLabel ? label : undefined}
aria-labelledby={hideLabel ? undefined : labelId}
/>
{showGenerateDraft && !disabled ? (
<div className="flex items-center">
@@ -119,6 +138,11 @@ function ModalTextAreaFieldComponent({
) : null}
</div>
) : null}
{showFilledHint ? (
<p className="text-[length:var(--sizing-300)] leading-normal text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
{filledHint}
</p>
) : null}
</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 type { CommunicationMethodEditFieldsProps } from "./CommunicationMethodEditFields";
@@ -3,8 +3,7 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import MultiSelect from "../../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
import Create from "../../../../components/modals/Create";
import ContentLockup from "../../../../components/type/ContentLockup";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { useMessages } from "../../../../contexts/MessagesContext";
import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft";
import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -16,8 +15,8 @@ import type {
} from "../../types";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { CoreValueEditFields } from "../../components/methodEditFields";
import CustomMethodCardModalBody from "../../components/CustomMethodCardModalBody";
import { ValueDialog } from "../../components/ValueDialog";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { usesWizardFieldBlocksModalBody } from "../../../../../lib/create/usesWizardFieldBlocksModalBody";
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
* tweak meaning/signals. Dismiss = no-op (chip stays as-is).
*
* **Add value** (and the header "add" link) opens an empty custom-policy
* wizard. Finalize writes a selected chip named from the wizard title.
* The header **add your own** link opens an empty custom-policy wizard.
* Finalize writes a selected chip named from the wizard title.
*/
type ModalSession = "pending" | "customPending" | "editing";
@@ -98,7 +97,7 @@ function chipOptionsToSnapshotRows(
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() {
const m = useMessages();
const cv = m.create.customRule.coreValues;
@@ -448,8 +447,6 @@ export function CoreValuesSelectScreen() {
}),
);
const showFooterPrimary = Boolean(modalSession);
const handleCustomize = useCallback(() => {
if (!activeModalChipId) return;
markCreateFlowInteraction();
@@ -457,8 +454,7 @@ export function CoreValuesSelectScreen() {
buildMethodCardWizardInitialValues({
cardId: activeModalChipId,
fallbackTitle: modalChipLabel,
fallbackDescription:
draft.supportText?.trim() || cv.detailModal.subtitle,
fallbackDescription: draft.supportText?.trim() || "",
meta: {},
persistedBlocks: state.customMethodCardFieldBlocksById,
draftFieldBlocks,
@@ -478,7 +474,6 @@ export function CoreValuesSelectScreen() {
activeModalChipId,
cv.detailModal.meaningLabel,
cv.detailModal.signalsLabel,
cv.detailModal.subtitle,
draft,
draftFieldBlocks,
markCreateFlowInteraction,
@@ -606,15 +601,12 @@ export function CoreValuesSelectScreen() {
atSelectionLimit
? undefined
: handleDuplicateCoreChip,
showRemove: modalSession === "editing",
onRemove: handleRemoveFromKebab,
});
}, [
activeModalChipId,
atSelectionLimit,
handleCustomize,
handleDuplicateCoreChip,
handleRemoveFromKebab,
modalKebabMenu,
modalSession,
state.editingPublishedRuleId,
@@ -677,7 +669,7 @@ export function CoreValuesSelectScreen() {
queueMicrotask(() => {
syncCoreValuesToDraft(next);
// 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);
});
return next;
@@ -695,26 +687,17 @@ export function CoreValuesSelectScreen() {
const description = (
<>
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
{cv.header.descriptionLead}{" "}
</span>
<button
type="button"
{cv.header.descriptionLead}
<InlineTextButton
onClick={addHandlers.onAddClick}
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}
</button>
<span className="leading-[1.3] text-[color:var(--color-content-default-tertiary,#b4b4b4)]">
{" "}
{cv.header.descriptionTrail}
</span>
</InlineTextButton>
{cv.header.descriptionTrail}
</>
);
const detailModal = cv.detailModal;
return (
<>
<CreateFlowTwoColumnSelectShell
@@ -735,75 +718,54 @@ export function CoreValuesSelectScreen() {
onAddClick={addHandlers.onAddClick}
onCustomChipConfirm={addHandlers.onCustomChipConfirm}
onCustomChipClose={addHandlers.onCustomChipClose}
addButton
addButtonText={cv.multiSelect.addButtonText}
addButton={false}
maxSelections={MAX_CORE_VALUES}
selectionCountText={selectionCountText}
limitReachedAnnouncement={cv.multiSelect.limitReached}
/>
{detailModal && (
<Create
isOpen={activeModalChipId !== null && !addCustomWizardOpen}
onClose={handleModalDismiss}
backdropVariant="blurredYellow"
headerContent={
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={modalChipLabel}
description={
draft.supportText?.trim() || detailModal.subtitle
}
variant="modal"
alignment="left"
/>
</div>
}
showBackButton={false}
showNextButton={showFooterPrimary}
showRemoveButton={modalSession === "editing"}
onRemove={handleRemoveFromKebab}
onNext={handleModalConfirm}
nextButtonText={
modalSession === "editing"
? modalKebabMenu.saveEdits
: detailModal.addValueButton
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={
kebabMenuItems.length > 0 ? kebabMenuItems : undefined
}
ariaLabel={modalChipLabel || "Core value details"}
>
{modalUsesWizardFieldBlocksBody && activeModalChipId ? (
<CustomMethodCardModalBody
cardId={activeModalChipId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
showPolicyContentLockupWhenNoBlocks={false}
onFieldBlocksChange={
draftFieldBlocks === null
? undefined
: (next) => {
setDraftFieldBlocks(next);
setDraft(
coreValueDetailsFromWizardFieldBlocks(next, draft),
);
}
}
/>
) : (
<CoreValueEditFields
value={draft}
onChange={handleDraftChange}
generateDraft={getPresetTexts(modalChipLabel)}
/>
)}
</Create>
)}
<ValueDialog
isOpen={activeModalChipId !== null && !addCustomWizardOpen}
valueLabel={modalChipLabel}
session={modalSession === "editing" ? "editing" : "pending"}
draft={draft}
onDraftChange={handleDraftChange}
generateDraft={getPresetTexts(modalChipLabel)}
onClose={() => {
void handleModalDismiss();
}}
onConfirm={handleModalConfirm}
onDeselect={
modalSession === "editing" ? handleRemoveFromKebab : undefined
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={
kebabMenuItems.length > 0 ? kebabMenuItems : undefined
}
ariaLabel={modalChipLabel || "Core value details"}
>
{modalUsesWizardFieldBlocksBody && activeModalChipId ? (
<CustomMethodCardModalBody
cardId={activeModalChipId}
blocksById={state.customMethodCardFieldBlocksById}
blocksOverride={
draftFieldBlocks !== null ? draftFieldBlocks : undefined
}
showPolicyContentLockupWhenNoBlocks={false}
onFieldBlocksChange={
draftFieldBlocks === null
? undefined
: (next) => {
setDraftFieldBlocks(next);
setDraft(
coreValueDetailsFromWizardFieldBlocks(next, draft),
);
}
}
/>
) : undefined}
</ValueDialog>
</CreateFlowTwoColumnSelectShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
@@ -63,7 +63,7 @@ function MultiSelectView({
{!formHeader || !label ? (
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}
</p>
) : null
@@ -21,11 +21,14 @@ const CreateContainer = memo<CreateProps>(
footerClassName,
showBackButton = true,
showNextButton = true,
showCancelButton = false,
showRemoveButton = false,
onBack,
onNext,
onCancel,
onRemove,
backButtonText = "Back",
cancelButtonText,
nextButtonText = "Next",
removeButtonText,
nextButtonDisabled = false,
@@ -59,11 +62,14 @@ const CreateContainer = memo<CreateProps>(
footerClassName={footerClassName}
showBackButton={showBackButton}
showNextButton={showNextButton}
showCancelButton={showCancelButton}
showRemoveButton={showRemoveButton}
onBack={onBack}
onNext={onNext}
onCancel={onCancel}
onRemove={onRemove}
backButtonText={backButtonText}
cancelButtonText={cancelButtonText}
nextButtonText={nextButtonText}
removeButtonText={removeButtonText}
nextButtonDisabled={nextButtonDisabled}
@@ -16,11 +16,14 @@ export interface CreateProps {
footerClassName?: string;
showBackButton?: boolean;
showNextButton?: boolean;
showCancelButton?: boolean;
showRemoveButton?: boolean;
onBack?: () => void;
onNext?: () => void;
onCancel?: () => void;
onRemove?: () => void;
backButtonText?: string;
cancelButtonText?: string;
nextButtonText?: string;
removeButtonText?: string;
nextButtonDisabled?: boolean;
@@ -61,11 +64,14 @@ export interface CreateViewProps {
footerClassName?: string;
showBackButton: boolean;
showNextButton: boolean;
showCancelButton: boolean;
showRemoveButton: boolean;
onBack?: () => void;
onNext?: () => void;
onCancel?: () => void;
onRemove?: () => void;
backButtonText: string;
cancelButtonText?: string;
nextButtonText: string;
removeButtonText?: string;
nextButtonDisabled: boolean;
@@ -17,11 +17,14 @@ export function CreateView({
footerClassName,
showBackButton,
showNextButton,
showCancelButton,
showRemoveButton,
onBack,
onNext,
onCancel,
onRemove,
backButtonText,
cancelButtonText,
nextButtonText,
removeButtonText,
nextButtonDisabled,
@@ -79,11 +82,14 @@ export function CreateView({
<ModalFooter
showBackButton={showBackButton}
showNextButton={showNextButton}
showCancelButton={showCancelButton}
showRemoveButton={showRemoveButton}
onBack={onBack}
onNext={onNext}
onCancel={onCancel}
onRemove={onRemove}
backButtonText={backButtonText}
cancelButtonText={cancelButtonText}
nextButtonText={nextButtonText}
removeButtonText={removeButtonText}
nextButtonDisabled={nextButtonDisabled}
@@ -13,6 +13,7 @@ import type { ModalFooterProps } from "./ModalFooter.types";
const ModalFooterContainer = memo<ModalFooterProps>((props) => {
const t = useTranslation("common");
const resolvedBackText = props.backButtonText ?? t("buttons.back");
const resolvedCancelText = props.cancelButtonText ?? t("buttons.cancel");
const resolvedNextText = props.nextButtonText ?? t("buttons.next");
const resolvedRemoveText = props.removeButtonText ?? t("buttons.remove");
@@ -20,6 +21,7 @@ const ModalFooterContainer = memo<ModalFooterProps>((props) => {
<ModalFooterView
{...props}
backButtonText={resolvedBackText}
cancelButtonText={resolvedCancelText}
nextButtonText={resolvedNextText}
removeButtonText={resolvedRemoveText}
/>
@@ -1,6 +1,10 @@
export interface ModalFooterProps {
showBackButton?: 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).
* Takes the left slot instead of Back when both would otherwise show.
@@ -8,11 +12,16 @@ export interface ModalFooterProps {
showRemoveButton?: boolean;
onBack?: () => void;
onNext?: () => void;
onCancel?: () => void;
onRemove?: () => void;
/**
* Custom back button text. If not provided, uses localized "Back" from common.json
*/
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
*/
@@ -7,11 +7,14 @@ import type { ModalFooterProps } from "./ModalFooter.types";
export function ModalFooterView({
showBackButton = false,
showNextButton = false,
showCancelButton = false,
showRemoveButton = false,
onBack,
onNext,
onCancel,
onRemove,
backButtonText,
cancelButtonText,
nextButtonText,
removeButtonText,
nextButtonDisabled = false,
@@ -26,6 +29,7 @@ export function ModalFooterView({
? stepperProp
: currentStep !== undefined && totalSteps !== undefined;
const showStartBack = showBackButton && !showRemoveButton;
const showTrailing = showCancelButton || showNextButton;
return (
<div
@@ -64,17 +68,29 @@ export function ModalFooterView({
</div>
)}
{showNextButton && (
<div className="absolute right-[16px] top-[12px]">
<Button
buttonType="filled"
palette="default"
size="medium"
onClick={onNext}
disabled={nextButtonDisabled}
>
{nextButtonText}
</Button>
{showTrailing && (
<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
buttonType="filled"
palette="default"
size="medium"
onClick={onNext}
disabled={nextButtonDisabled}
>
{nextButtonText}
</Button>
) : null}
</div>
)}
+1
View File
@@ -6,6 +6,7 @@
"learnMore": "Learn more",
"askOrganizer": "Ask an organizer",
"back": "Back",
"cancel": "Cancel",
"next": "Next",
"remove": "Remove",
"finish": "Finish"
+19 -7
View File
@@ -1,20 +1,32 @@
{
"header": {
"title": "Choose up to six core values",
"descriptionLead": "What does your group hold most dear? You can also combine or",
"addLink": "add",
"descriptionTrail": "new values to the list."
"descriptionLead": "Pick what your community holds most dear or ",
"addLink": "add your own",
"descriptionTrail": "."
},
"multiSelect": {
"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."
},
"detailModal": {
"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?",
"signalsLabel": "Signals of Violation",
"addValueButton": "Add Value",
"meaningLabel": "What does this value mean to your community?",
"meaningDescription": "How does your community interpret the meaning of this 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",
"discardTitle": "Discard unsaved changes?",
"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 { 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", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -13,19 +18,50 @@ describe("CoreValuesSelectScreen", () => {
fireEvent.click(await screen.findByRole("button", { name: "Discard" }));
}
async function editMeaningInOpenDialog(next: string) {
const dialog = await screen.findByRole("dialog");
const fields = within(dialog).getAllByRole("textbox");
fireEvent.change(fields[0], { target: { value: next } });
async function openValueDialog(label = "Accessibility") {
fireEvent.click(screen.getByRole("button", { name: `Select ${label}` }));
return screen.findByRole("dialog");
}
it("opens core value detail modal when a preset chip is clicked", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
async function editMeaningInOpenDialog(next: string) {
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(
within(dialog).getByRole("button", { name: "Add Value" }),
within(dialog).getByRole("button", { name: MEANING_STEP }),
).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(
within(dialog).queryByRole("button", { name: "Remove" }),
).not.toBeInTheDocument();
@@ -48,23 +84,73 @@ describe("CoreValuesSelectScreen", () => {
).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 />);
fireEvent.click(screen.getByRole("button", { name: "Select Accessibility" }));
const dialog = await screen.findByRole("dialog");
const fields = within(dialog).getAllByRole("textbox") as HTMLTextAreaElement[];
expect(fields[0].value).toBe("");
expect(fields[1].value).toBe("");
expect(
screen.getByRole("heading", { name: "Choose up to six core values" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: ADD_OWN })).toBeInTheDocument();
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(
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 () => {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
await screen.findByRole("dialog");
await openValueDialog();
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@@ -72,6 +158,9 @@ describe("CoreValuesSelectScreen", () => {
expect(
screen.queryByRole("button", { name: "Keep editing" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Select Accessibility" }),
).toBeInTheDocument();
});
it("asks to discard when closing a pending value after an edit", async () => {
@@ -79,6 +168,7 @@ describe("CoreValuesSelectScreen", () => {
fireEvent.click(screen.getByText("Accessibility"));
await editMeaningInOpenDialog("Changed meaning");
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.keyDown(document, { key: "Escape" });
expect(
await screen.findByRole("button", { name: "Keep editing" }),
).toBeInTheDocument();
@@ -90,8 +180,7 @@ describe("CoreValuesSelectScreen", () => {
it("does not block tab close when a pending value is unchanged", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
await screen.findByRole("dialog");
await openValueDialog();
expect(dispatchBeforeUnload()).toBe(false);
});
@@ -107,53 +196,55 @@ describe("CoreValuesSelectScreen", () => {
fireEvent.click(screen.getByText("Accessibility"));
await editMeaningInOpenDialog("Changed meaning");
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.click(await screen.findByRole("button", { name: "Keep editing" }));
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByRole("button", { name: "Add Value" }),
within(dialog).getByRole("button", { name: ADD_TO_RULE }),
).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 />);
fireEvent.click(screen.getByText("Accessibility"));
const dialog = await screen.findByRole("dialog");
fireEvent.click(
within(dialog).getByRole("button", { name: "Add Value" }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
const dialog = await openValueDialog();
await confirmPendingValue(dialog);
expect(
screen.getByRole("button", { name: "Deselect Accessibility" }),
).toBeInTheDocument();
expect(screen.getByText("1 of 6 picked")).toBeInTheDocument();
});
it("opens meaning and signals fields editable without Customize", async () => {
it("opens meaning and signals steps editable without Customize", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
const dialog = await screen.findByRole("dialog");
const fields = within(dialog).getAllByRole("textbox");
expect(fields.length).toBeGreaterThanOrEqual(2);
for (const field of fields) {
expect(field).toBeEnabled();
}
const dialog = await openValueDialog();
fireEvent.click(within(dialog).getByRole("button", { name: MEANING_STEP }));
expect(within(dialog).getByRole("textbox")).toBeEnabled();
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
fireEvent.click(within(dialog).getByRole("button", { name: SIGNALS_STEP }));
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 />);
fireEvent.click(screen.getByText("Accessibility"));
const pending = await screen.findByRole("dialog");
fireEvent.click(within(pending).getByRole("button", { name: "Add Value" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
const pending = await openValueDialog();
await confirmPendingValue(pending);
fireEvent.click(screen.getByText("Accessibility"));
const editing = await screen.findByRole("dialog");
expect(
within(editing).getByRole("button", { name: "Remove" }),
within(editing).getByRole("button", { name: "Deselect" }),
).toBeInTheDocument();
expect(
within(editing).getByRole("button", { name: "Save" }),
).toBeInTheDocument();
expect(
within(editing).queryByRole("button", { name: "Remove" }),
).not.toBeInTheDocument();
fireEvent.click(
within(editing).getByRole("button", { name: "More options" }),
);
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
expect(
screen.queryByRole("menuitem", { name: "Remove" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: "Customize" }),
).toBeInTheDocument();
@@ -162,10 +253,25 @@ describe("CoreValuesSelectScreen", () => {
).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 () => {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
const dialog = await screen.findByRole("dialog");
const dialog = await openValueDialog();
fireEvent.click(
within(dialog).getByRole("button", { name: "More options" }),
);
@@ -176,13 +282,14 @@ describe("CoreValuesSelectScreen", () => {
);
expect(screen.queryByText("Custom policy details")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByPlaceholderText("Policy description")).toHaveValue("");
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(
await screen.findByText("Custom policy details"),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: "What does this value mean to your group?",
name: MEANING_STEP,
}),
).toBeInTheDocument();
});
@@ -190,15 +297,9 @@ describe("CoreValuesSelectScreen", () => {
it("Customize shows meaning saved from the value modal", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
const pending = await screen.findByRole("dialog");
const fields = within(pending).getAllByRole("textbox");
fireEvent.change(fields[0], {
target: { value: "Edited meaning from value modal" },
});
fireEvent.click(within(pending).getByRole("button", { name: "Add Value" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
await editMeaningInOpenDialog("Edited meaning from value modal");
const pending = screen.getByRole("dialog");
await confirmPendingValue(pending);
fireEvent.click(screen.getByText("Accessibility"));
const editing = await screen.findByRole("dialog");
fireEvent.click(
@@ -207,10 +308,11 @@ describe("CoreValuesSelectScreen", () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
expect(await screen.findByPlaceholderText("Policy name")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByPlaceholderText("Policy description")).toHaveValue("");
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(
await screen.findByRole("button", {
name: "What does this value mean to your group?",
name: MEANING_STEP,
}),
);
expect(
@@ -220,8 +322,7 @@ describe("CoreValuesSelectScreen", () => {
it("closing Customize without Finalize returns to the value modal", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
const dialog = await screen.findByRole("dialog");
const dialog = await openValueDialog();
fireEvent.click(
within(dialog).getByRole("button", { name: "More options" }),
);
@@ -237,14 +338,13 @@ describe("CoreValuesSelectScreen", () => {
});
const valueDialog = screen.getByRole("dialog");
expect(
within(valueDialog).getByRole("button", { name: "Add Value" }),
within(valueDialog).getByRole("button", { name: ADD_TO_RULE }),
).toBeInTheDocument();
});
it("Customize Finalize shows reordered fields in the value modal", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
fireEvent.click(screen.getByText("Accessibility"));
const dialog = await screen.findByRole("dialog");
const dialog = await openValueDialog();
fireEvent.click(
within(dialog).getByRole("button", { name: "More options" }),
);
@@ -292,21 +392,13 @@ describe("CoreValuesSelectScreen", () => {
? (document.getElementById(labelledby)?.textContent ?? "").trim()
: "";
});
expect(labels[0]).toMatch(/Signals of Violation/);
expect(labels[1]).toMatch(/What does this value mean to your group/);
expect(labels[0]).toMatch(/Signals of violation/i);
expect(labels[1]).toMatch(/What does this value mean to your community/);
});
// The "Add value" control opens an empty custom-policy wizard. Dismissing
// without Finalize leaves the chip list unchanged. Finalize writes a
// selected chip named from the wizard title.
describe("Add value wizard", () => {
describe("add your own wizard", () => {
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) {
return screen
.queryAllByRole("button", {
@@ -316,12 +408,13 @@ describe("CoreValuesSelectScreen", () => {
}
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");
fireEvent.change(nameInput, { target: { value: label } });
fireEvent.click(screen.getByRole("button", { name: "Next" }));
const descriptionInput =
await screen.findByPlaceholderText("Policy description");
expect(descriptionInput).toHaveValue("");
fireEvent.change(descriptionInput, {
target: { value: "A community-authored value." },
});
@@ -337,7 +430,7 @@ describe("CoreValuesSelectScreen", () => {
it("collapses the field-type picker after adding a custom field", async () => {
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"), {
target: { value: CUSTOM_LABEL },
});
@@ -373,18 +466,18 @@ describe("CoreValuesSelectScreen", () => {
).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 />);
fireEvent.click(screen.getByRole("button", { name: "Add value" }));
fireEvent.click(screen.getByRole("button", { name: ADD_OWN }));
expect(
await screen.findByPlaceholderText("Policy name"),
).toHaveValue("");
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 />);
fireEvent.click(screen.getByRole("button", { name: "Add value" }));
fireEvent.click(screen.getByRole("button", { name: ADD_OWN }));
await screen.findByPlaceholderText("Policy name");
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
@@ -412,7 +505,7 @@ describe("CoreValuesSelectScreen", () => {
async function addPresetValue(label: string) {
fireEvent.click(screen.getByText(label));
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(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
@@ -420,7 +513,7 @@ describe("CoreValuesSelectScreen", () => {
it("shows a counter, disables remaining chips, and announces the limit", async () => {
renderWithProviders(<CoreValuesSelectScreen />);
expect(screen.getByText("0 of 6")).toBeInTheDocument();
expect(screen.getByText("0 of 6 picked")).toBeInTheDocument();
await addPresetValue("Accessibility");
await addPresetValue("Accountability");
@@ -429,7 +522,7 @@ describe("CoreValuesSelectScreen", () => {
await addPresetValue("Anti-oppression");
await addPresetValue("Autonomy");
expect(screen.getByText("6 of 6")).toBeInTheDocument();
expect(screen.getByText("6 of 6 picked")).toBeInTheDocument();
expect(
screen.getByText(
"Six of six values selected. Remaining values are unavailable until one is removed.",
@@ -438,8 +531,7 @@ describe("CoreValuesSelectScreen", () => {
expect(
screen.getByRole("button", { name: "Select Capacity building" }),
).toBeDisabled();
expect(screen.getByRole("button", { name: "Add value" })).toBeDisabled();
expect(screen.getByRole("button", { name: /^add$/i })).toBeDisabled();
expect(screen.getByRole("button", { name: ADD_OWN })).toBeDisabled();
});
});
});
+20
View File
@@ -122,6 +122,26 @@ describe("Create", () => {
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", () => {
const onNext = vi.fn();
renderWithProviders(
+47 -2
View File
@@ -358,6 +358,9 @@ describe("FinalReviewScreen — chip detail modal", () => {
expect(
screen.getByRole("menuitem", { name: /^customize$/i }),
).toBeInTheDocument();
expect(
screen.queryByRole("menuitem", { name: /^remove$/i }),
).not.toBeInTheDocument();
});
it("values Customize starts on the policy name step", async () => {
@@ -388,6 +391,8 @@ describe("FinalReviewScreen — chip detail modal", () => {
await screen.findByPlaceholderText("Policy name"),
).toHaveValue("Accessibility");
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 () => {
@@ -432,14 +437,48 @@ describe("FinalReviewScreen — chip detail modal", () => {
await waitFor(() => {
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();
});
expect(
screen.getByText(/signals of violation/i),
screen.getByRole("button", { name: /signals of violation/i }),
).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 () => {
// Plain custom-rule path: snapshot is set, sections is not.
function CoreValuesHarness() {
@@ -463,6 +502,12 @@ describe("FinalReviewScreen — chip detail modal", () => {
expect(
within(dialog).getByRole("button", { name: "Save" }),
).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(
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 {
componentTestSuite,
type ComponentTestSuiteConfig,
@@ -27,4 +30,26 @@ const config: ComponentTestSuiteConfig<Props> = {
describe("ModalFooter", () => {
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");
});
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", () => {
renderWithProviders(
<ModalTextAreaField
+12
View File
@@ -189,6 +189,18 @@ describe("MultiSelect – behaviour specifics", () => {
).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 () => {
const handleConfirm = vi.fn();
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.submitEmail).toBe("Submit email");
});
it("exposes Confirm values for the core-values footer CTA", () => {
expect(messages.create.footer.confirmCoreValues).toBe("Confirm values");
});
});