Open each core value as a card of meaning and signals steps, so groups write their own words from an empty field with Generate draft, skip values without blocking the flow, and Customize without keeping helper text as the description.

This commit is contained in:
adilallo committed 2026-11-05 21:28:00 -07:00
1 parent 2a7b9e6da7
commit 64a542f456
25 files changed
+877 -354

No files matched your search

+53 -28
View File
@@ -1065,35 +1065,60 @@ function CreateFlowLayoutContent({
</div> </div>
) : showCustomRuleFooterConfirm && ) : showCustomRuleFooterConfirm &&
customRuleConfirmFooter ? ( customRuleConfirmFooter ? (
<Button <div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
buttonType="filled" {customRuleConfirmFooter.step === "core-values" ? (
palette="default" <Button
size="xsmall" buttonType="ghost"
disabled={ palette="default"
isPublishing || size="xsmall"
customRuleConfirmFooter.selectionIds(state).length === 0 disabled={isPublishing}
} className={CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS} onClick={() => {
onClick={() => { if (reviewReturnTarget) {
const cf = customRuleConfirmFooter; router.push(
const facet = methodCardFacetSectionForConfirmStep(cf.step); createFlowStepPathAfterStrippingReviewReturn(
if (facet != null && cf.selectionIds(state).length > 0) { reviewReturnTarget,
setMethodSectionsPinCommitted(facet, true); searchParams,
),
);
return;
}
goToNextStep();
}}
>
{footer.skipForNow}
</Button>
) : null}
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
customRuleConfirmFooter.selectionIds(state).length === 0
} }
if (reviewReturnTarget) { className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
router.push( onClick={() => {
createFlowStepPathAfterStrippingReviewReturn( const cf = customRuleConfirmFooter;
reviewReturnTarget, const facet = methodCardFacetSectionForConfirmStep(cf.step);
searchParams, if (facet != null && cf.selectionIds(state).length > 0) {
), setMethodSectionsPinCommitted(facet, true);
); }
return; if (reviewReturnTarget) {
} router.push(
goToNextStep(); createFlowStepPathAfterStrippingReviewReturn(
}} reviewReturnTarget,
> searchParams,
{footer[customRuleConfirmFooter.footerMessageKey]} ),
</Button> );
return;
}
goToNextStep();
}}
>
{footer[customRuleConfirmFooter.footerMessageKey]}
</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,
@@ -485,8 +485,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 +558,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,
@@ -730,8 +728,6 @@ export function FinalReviewChipEditModal({
(state.selectedCoreValueIds ?? []).length >= 5 (state.selectedCoreValueIds ?? []).length >= 5
? undefined ? undefined
: handleDuplicateCoreValue, : handleDuplicateCoreValue,
showRemove: true,
onRemove: handleRemoveCoreValueFromModal,
}); });
} }
if (!isMethodFacetGroup(target.groupKey)) return []; if (!isMethodFacetGroup(target.groupKey)) return [];
@@ -744,7 +740,6 @@ export function FinalReviewChipEditModal({
}, [ }, [
handleCustomize, handleCustomize,
handleDuplicateCoreValue, handleDuplicateCoreValue,
handleRemoveCoreValueFromModal,
handleRemoveSelectedFromModal, handleRemoveSelectedFromModal,
isChipInSelection, isChipInSelection,
modalKebabMenu, modalKebabMenu,
@@ -1080,12 +1075,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 +1157,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 +1472,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,26 +80,34 @@ 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()}
> >
<div id={labelId}> {hideLabel ? null : (
<InputLabel <div id={labelId}>
label={label} <InputLabel
helpIcon={helpIcon} label={label}
size="s" helpIcon={helpIcon}
palette="default" size="s"
/> 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,75 +718,54 @@ 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} valueLabel={modalChipLabel}
onClose={handleModalDismiss} session={modalSession === "editing" ? "editing" : "pending"}
backdropVariant="blurredYellow" draft={draft}
headerContent={ onDraftChange={handleDraftChange}
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0"> generateDraft={getPresetTexts(modalChipLabel)}
<ContentLockup onClose={() => {
title={modalChipLabel} void handleModalDismiss();
description={ }}
draft.supportText?.trim() || detailModal.subtitle onConfirm={handleModalConfirm}
} onDeselect={
variant="modal" modalSession === "editing" ? handleRemoveFromKebab : undefined
alignment="left" }
/> kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
</div> kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
} kebabMenuItems={
showBackButton={false} kebabMenuItems.length > 0 ? kebabMenuItems : undefined
showNextButton={showFooterPrimary} }
showRemoveButton={modalSession === "editing"} ariaLabel={modalChipLabel || "Core value details"}
onRemove={handleRemoveFromKebab} >
onNext={handleModalConfirm} {modalUsesWizardFieldBlocksBody && activeModalChipId ? (
nextButtonText={ <CustomMethodCardModalBody
modalSession === "editing" cardId={activeModalChipId}
? modalKebabMenu.saveEdits blocksById={state.customMethodCardFieldBlocksById}
: detailModal.addValueButton blocksOverride={
} draftFieldBlocks !== null ? draftFieldBlocks : undefined
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} }
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} showPolicyContentLockupWhenNoBlocks={false}
kebabMenuItems={ onFieldBlocksChange={
kebabMenuItems.length > 0 ? kebabMenuItems : undefined draftFieldBlocks === null
} ? undefined
ariaLabel={modalChipLabel || "Core value details"} : (next) => {
> setDraftFieldBlocks(next);
{modalUsesWizardFieldBlocksBody && activeModalChipId ? ( setDraft(
<CustomMethodCardModalBody coreValueDetailsFromWizardFieldBlocks(next, draft),
cardId={activeModalChipId} );
blocksById={state.customMethodCardFieldBlocksById} }
blocksOverride={ }
draftFieldBlocks !== null ? draftFieldBlocks : undefined />
} ) : undefined}
showPolicyContentLockupWhenNoBlocks={false} </ValueDialog>
onFieldBlocksChange={
draftFieldBlocks === null
? undefined
: (next) => {
setDraftFieldBlocks(next);
setDraft(
coreValueDetailsFromWizardFieldBlocks(next, draft),
);
}
}
/>
) : (
<CoreValueEditFields
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,17 +68,29 @@ 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]">
<Button {showCancelButton ? (
buttonType="filled" <Button
palette="default" buttonType="outline"
size="medium" palette="default"
onClick={onNext} size="medium"
disabled={nextButtonDisabled} onClick={onCancel}
> >
{nextButtonText} {cancelButtonText}
</Button> </Button>
) : null}
{showNextButton ? (
<Button
buttonType="filled"
palette="default"
size="medium"
onClick={onNext}
disabled={nextButtonDisabled}
>
{nextButtonText}
</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(
+47 -2
View File
@@ -358,6 +358,9 @@ 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("values Customize starts on the policy name step", async () => { it("values Customize starts on the policy name step", async () => {
@@ -388,6 +391,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 +437,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 +502,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");
});
}); });