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:
1 parent
2a7b9e6da7
commit
64a542f456
25 files changed
+877
-354
No files matched your search
@@ -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"
|
||||
|
||||
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"learnMore": "Learn more",
|
||||
"askOrganizer": "Ask an organizer",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"next": "Next",
|
||||
"remove": "Remove",
|
||||
"finish": "Finish"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user