Show seven cards on the four method decks and open each as a routing overlay of its parts #99

Merged
an.di merged 2 commits from adilallo/fix/CR-281-four-decks into main 2026-11-06 15:14:53 +00:00
28 changed files with 1334 additions and 254 deletions

No files matched your search

@@ -0,0 +1,144 @@
"use client";
/**
* Figma: Overlay / Modal / Card (24973:1852) with Has Steps on. A deck card
* opens as a routing view of its parts; each row opens that field's Step
* Modal 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 { ValueDialogStepCard } from "./ValueDialogStepCard";
export type MethodCardPolicyStep = {
id: string;
title: string;
emptyDescription: string;
done: boolean;
summary: string;
renderEditor: () => ReactNode;
};
export type MethodCardPolicyDialogProps = {
isOpen: boolean;
cardLabel: string;
description?: string;
editing: boolean;
steps: MethodCardPolicyStep[];
onClose: () => void;
onConfirm: () => void;
onDeselect?: () => void;
kebabTriggerAriaLabel?: string;
kebabMenuAriaLabel?: string;
kebabMenuItems?: ModalHeaderMenuItem[];
ariaLabel?: string;
/** Customized field-blocks body. Replaces the routing steps. */
children?: ReactNode;
};
function MethodCardPolicyDialogComponent({
isOpen,
cardLabel,
description = "",
editing,
steps,
onClose,
onConfirm,
onDeselect,
kebabTriggerAriaLabel,
kebabMenuAriaLabel,
kebabMenuItems,
ariaLabel,
children,
}: MethodCardPolicyDialogProps) {
const m = useMessages();
const t = m.create.customRule.cardDialog;
const [activeStepId, setActiveStepId] = useState<string | null>(null);
useEffect(() => {
if (!isOpen) {
setActiveStepId(null);
}
}, [isOpen]);
const activeStep =
activeStepId == null
? null
: (steps.find((step) => step.id === activeStepId) ?? null);
const useCustomBody = Boolean(children);
const showSteps = !useCustomBody && activeStep == null;
const handleRequestClose = useCallback(() => {
if (activeStepId != null) {
setActiveStepId(null);
return;
}
onClose();
}, [activeStepId, onClose]);
const handleNext = useCallback(() => {
if (activeStepId != null) {
setActiveStepId(null);
return;
}
onConfirm();
}, [activeStepId, onConfirm]);
const title = activeStep ? activeStep.title : cardLabel;
const primaryLabel = activeStepId != null || editing ? t.save : t.addToRule;
return (
<Create
isOpen={isOpen}
onClose={handleRequestClose}
title={title}
description={activeStep ? undefined : description || undefined}
showBackButton={false}
showCancelButton
showNextButton
showRemoveButton={editing && activeStepId == null}
onCancel={handleRequestClose}
onNext={handleNext}
onRemove={onDeselect}
cancelButtonText={t.cancel}
nextButtonText={primaryLabel}
removeButtonText={t.deselect}
stepper={false}
backdropVariant="blurredYellow"
kebabTriggerAriaLabel={kebabTriggerAriaLabel}
kebabMenuAriaLabel={kebabMenuAriaLabel}
kebabMenuItems={
activeStepId == null && kebabMenuItems && kebabMenuItems.length > 0
? kebabMenuItems
: undefined
}
ariaLabel={ariaLabel || cardLabel}
>
{useCustomBody ? (
children
) : showSteps ? (
<div className="flex w-full flex-col gap-[8px]">
{steps.map((step, index) => (
<ValueDialogStepCard
key={step.id}
stepNumber={index + 1}
stepBadgeLabel={t.stepBadge}
title={step.title}
description={step.done ? step.summary : step.emptyDescription}
done={step.done}
onClick={() => setActiveStepId(step.id)}
/>
))}
</div>
) : (
activeStep?.renderEditor()
)}
</Create>
);
}
MethodCardPolicyDialogComponent.displayName = "MethodCardPolicyDialog";
export const MethodCardPolicyDialog = memo(MethodCardPolicyDialogComponent);
@@ -0,0 +1,325 @@
"use client";
import IncrementerBlock from "../../../components/controls/IncrementerBlock";
import { useMessages } from "../../../contexts/MessagesContext";
import { formatConflictApplicableScopeForTextarea } from "../../../../lib/create/ruleSectionsFromMethodSelections";
import { withDecisionApproachKeyResourceScopes } from "../../../../lib/create/decisionApproachKeyResources";
import type {
CommunicationMethodDetailEntry,
ConflictManagementDetailEntry,
DecisionApproachDetailEntry,
MembershipMethodDetailEntry,
} from "../types";
import ApplicableScopeField from "./ApplicableScopeField";
import type { MethodCardPolicyStep } from "./MethodCardPolicyDialog";
import ModalTextAreaField from "./ModalTextAreaField";
const CONSENSUS_LEVEL_MIN = 0;
const CONSENSUS_LEVEL_MAX = 100;
const CONSENSUS_LEVEL_STEP = 5;
function textStep(args: {
id: string;
title: string;
emptyDescription: string;
value: string;
onChange: (next: string) => void;
generateDraftText?: string;
generateDraftLabel: string;
generateDraftHint: string;
filledHint: string;
placeholder: string;
}): MethodCardPolicyStep {
const done = args.value.trim().length > 0;
return {
id: args.id,
title: args.title,
emptyDescription: args.emptyDescription,
done,
summary: args.value,
renderEditor: () => (
<ModalTextAreaField
hideLabel
label={args.title}
value={args.value}
onChange={args.onChange}
placeholder={args.placeholder}
generateDraftText={args.generateDraftText}
generateDraftLabel={args.generateDraftLabel}
generateDraftHint={args.generateDraftHint}
filledHint={args.filledHint}
autoFocus
/>
),
};
}
function usePolicyFieldCopy() {
const m = useMessages();
return {
dialog: m.create.customRule.cardDialog,
draftField: m.create.draftField,
};
}
export function useCommunicationPolicySteps(
value: CommunicationMethodDetailEntry,
onChange: (next: CommunicationMethodDetailEntry) => void,
generateDraft?: CommunicationMethodDetailEntry,
): MethodCardPolicyStep[] {
const { dialog, draftField } = usePolicyFieldCopy();
const headings = useMessages().create.customRule.communication.sectionHeadings;
const fields: Array<keyof CommunicationMethodDetailEntry> = [
"corePrinciple",
"logisticsAdmin",
"codeOfConduct",
];
return fields.map((field) =>
textStep({
id: field,
title: headings[field],
emptyDescription: dialog.emptyStepDescription,
value: value[field],
onChange: (next) => onChange({ ...value, [field]: next }),
generateDraftText: generateDraft?.[field],
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
);
}
export function useMembershipPolicySteps(
value: MembershipMethodDetailEntry,
onChange: (next: MembershipMethodDetailEntry) => void,
generateDraft?: MembershipMethodDetailEntry,
): MethodCardPolicyStep[] {
const { dialog, draftField } = usePolicyFieldCopy();
const headings = useMessages().create.customRule.membership.sectionHeadings;
const fields: Array<keyof MembershipMethodDetailEntry> = [
"eligibility",
"joiningProcess",
"expectations",
];
return fields.map((field) =>
textStep({
id: field,
title: headings[field],
emptyDescription: dialog.emptyStepDescription,
value: value[field],
onChange: (next) => onChange({ ...value, [field]: next }),
generateDraftText: generateDraft?.[field],
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
);
}
export function useConflictPolicySteps(
value: ConflictManagementDetailEntry,
onChange: (next: ConflictManagementDetailEntry) => void,
generateDraft?: ConflictManagementDetailEntry,
): MethodCardPolicyStep[] {
const { dialog, draftField } = usePolicyFieldCopy();
const t = useMessages().create.customRule.conflictManagement;
const scopeText = formatConflictApplicableScopeForTextarea(
value.selectedApplicableScope,
value.applicableScope,
);
const generateScope =
generateDraft && generateDraft.applicableScope.length > 0
? generateDraft.applicableScope.join("\n")
: generateDraft
? formatConflictApplicableScopeForTextarea(
generateDraft.selectedApplicableScope,
generateDraft.applicableScope,
)
: undefined;
return [
textStep({
id: "corePrinciple",
title: t.sectionHeadings.corePrinciple,
emptyDescription: dialog.emptyStepDescription,
value: value.corePrinciple,
onChange: (next) => onChange({ ...value, corePrinciple: next }),
generateDraftText: generateDraft?.corePrinciple,
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
textStep({
id: "applicableScope",
title: t.sectionHeadings.applicableScope,
emptyDescription: t.applicableScopePlaceholder,
value: scopeText,
onChange: (next) => {
const lines = next
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
onChange({
...value,
applicableScope: lines,
selectedApplicableScope: [...lines],
});
},
generateDraftText: generateScope,
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: t.applicableScopePlaceholder,
}),
textStep({
id: "processProtocol",
title: t.sectionHeadings.processProtocol,
emptyDescription: dialog.emptyStepDescription,
value: value.processProtocol,
onChange: (next) => onChange({ ...value, processProtocol: next }),
generateDraftText: generateDraft?.processProtocol,
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
textStep({
id: "restorationFallbacks",
title: t.sectionHeadings.restorationFallbacks,
emptyDescription: dialog.emptyStepDescription,
value: value.restorationFallbacks,
onChange: (next) => onChange({ ...value, restorationFallbacks: next }),
generateDraftText: generateDraft?.restorationFallbacks,
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
];
}
export function useDecisionApproachPolicySteps(
value: DecisionApproachDetailEntry,
onChange: (next: DecisionApproachDetailEntry) => void,
generateDraft?: DecisionApproachDetailEntry,
): MethodCardPolicyStep[] {
const { dialog, draftField } = usePolicyFieldCopy();
const t = useMessages().create.customRule.decisionApproaches;
const scopes = withDecisionApproachKeyResourceScopes(value.applicableScope);
const selectedSummary = value.selectedApplicableScope.join(", ");
const consensusStep: MethodCardPolicyStep = {
id: "consensusLevel",
title: t.sectionHeadings.consensusLevel,
emptyDescription: t.consensusLevelDisplay.supermajorityShare,
done: true,
summary:
value.consensusLevel === undefined
? t.consensusLevelDisplay.supermajorityShare
: value.consensusLevel === 0
? t.consensusLevelDisplay.noSetShare
: `${value.consensusLevel}%`,
renderEditor: () =>
value.consensusLevel === undefined ? (
<ModalTextAreaField
hideLabel
label={t.sectionHeadings.consensusLevel}
value={t.consensusLevelDisplay.supermajorityShare}
onChange={() => {}}
disabled
rows={1}
/>
) : (
<IncrementerBlock
label={t.sectionHeadings.consensusLevel}
helpIcon={false}
value={value.consensusLevel}
min={CONSENSUS_LEVEL_MIN}
max={CONSENSUS_LEVEL_MAX}
step={CONSENSUS_LEVEL_STEP}
onChange={(next) => onChange({ ...value, consensusLevel: next })}
formatValue={(v) =>
v === 0 ? t.consensusLevelDisplay.noSetShare : `${v}%`
}
decrementAriaLabel={dialog.decreaseConsensusLevel}
incrementAriaLabel={dialog.increaseConsensusLevel}
/>
),
};
const scopeStep: MethodCardPolicyStep = {
id: "applicableScope",
title: t.sectionHeadings.applicableScope,
emptyDescription: dialog.scopeEmptyDescription,
done: value.selectedApplicableScope.length > 0,
summary: selectedSummary,
renderEditor: () => (
<ApplicableScopeField
label={t.sectionHeadings.applicableScope}
addLabel={t.scopeAddButtonLabel}
scopes={scopes}
selectedScopes={value.selectedApplicableScope}
onToggleScope={(scope) =>
onChange({
...value,
selectedApplicableScope: value.selectedApplicableScope.includes(
scope,
)
? value.selectedApplicableScope.filter((item) => item !== scope)
: [...value.selectedApplicableScope, scope],
})
}
onAddScope={(scope) =>
onChange({
...value,
applicableScope: [...value.applicableScope, scope],
})
}
/>
),
};
return [
textStep({
id: "corePrinciple",
title: t.sectionHeadings.corePrinciple,
emptyDescription: dialog.emptyStepDescription,
value: value.corePrinciple,
onChange: (next) => onChange({ ...value, corePrinciple: next }),
generateDraftText: generateDraft?.corePrinciple,
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
scopeStep,
textStep({
id: "stepByStepInstructions",
title: t.sectionHeadings.stepByStepInstructions,
emptyDescription: dialog.emptyStepDescription,
value: value.stepByStepInstructions,
onChange: (next) => onChange({ ...value, stepByStepInstructions: next }),
generateDraftText: generateDraft?.stepByStepInstructions,
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
consensusStep,
textStep({
id: "objectionsDeadlocks",
title: t.sectionHeadings.objectionsDeadlocks,
emptyDescription: dialog.emptyStepDescription,
value: value.objectionsDeadlocks,
onChange: (next) => onChange({ ...value, objectionsDeadlocks: next }),
generateDraftText: generateDraft?.objectionsDeadlocks,
generateDraftLabel: draftField.generateDraft,
generateDraftHint: draftField.generateDraftHint,
filledHint: dialog.filledHint,
placeholder: draftField.placeholder,
}),
];
}
@@ -14,7 +14,10 @@ import {
deckData,
recommendDeck,
} from "../../../../lib/create/deckRecommend";
import type { GroupAnswers } from "../../../../lib/create/deckTypes";
import type {
GroupAnswers,
RecommendedCard,
} from "../../../../lib/create/deckTypes";
/**
* Card-deck section ids served by `/api/create-flow/methods` (CR-88 §9.2).
@@ -143,8 +146,13 @@ export function deriveCompactCards(
compactCardIds: string[];
recommendedIds: Set<string>;
orderedIds: string[];
fallback: boolean;
recommendedById: Map<string, RecommendedCard>;
} {
const recommendation = recommendDeck(deckData(section), answers, { limit });
const recommendedById = new Map(
recommendation.recommended.map((card) => [card.id, card]),
);
const orderedIds = recommendation.fallback
? recommendation.seeAll
: [
@@ -156,6 +164,8 @@ export function deriveCompactCards(
compactCardIds: orderedIds.slice(0, limit),
recommendedIds: new Set<string>(),
orderedIds,
fallback: true,
recommendedById,
};
}
return {
@@ -166,6 +176,8 @@ export function deriveCompactCards(
.map((card) => card.id),
),
orderedIds,
fallback: false,
recommendedById,
};
}
@@ -2,12 +2,15 @@
import { useMemo } from "react";
import { mergeCompactCardIdsWithPinnedSelected } from "../../../../lib/create/methodCardDisplayOrder";
import { formatRecommendationWhyLine } from "../../../../lib/create/formatRecommendationWhyLine";
import {
deriveCompactCards,
groupAnswersForRecommendations,
type RecommendationSection,
} from "./useFacetRecommendations";
import { deckData } from "../../../../lib/create/deckRecommend";
import { useCreateFlow } from "../context/CreateFlowContext";
import { useMessages } from "../../../contexts/MessagesContext";
type MethodEntry = { id: string; label: string; supportText: string };
@@ -25,9 +28,8 @@ function orderByIds<T extends { id: string }>(
}
/**
* Deck order and the compact stack come from the job rule. Current screens
* show five cards; a deck screen passes seven. Selected cards still pin
* into the compact stack.
* Deck order and the compact stack come from the job rule. Compact shows
* at most seven recommended cards. Selected cards still pin into the stack.
*/
export function useMethodCardDeckOrdering(
section: RecommendationSection,
@@ -35,13 +37,15 @@ export function useMethodCardDeckOrdering(
selectedIds: readonly string[],
) {
const { state } = useCreateFlow();
const copy = useMessages().create.customRule.recommendations;
const compactLimit = deckData(section).config.displayLimit;
const answers = useMemo(
() => groupAnswersForRecommendations(state),
[state],
);
const deck = useMemo(
() => deriveCompactCards(section, answers, 5),
[section, answers],
() => deriveCompactCards(section, answers, compactLimit),
[section, answers, compactLimit],
);
const rankedMethods = useMemo(
@@ -59,25 +63,57 @@ export function useMethodCardDeckOrdering(
deck.compactCardIds,
selectedIds,
selectionShowcaseActive,
5,
compactLimit,
),
[
rankedMethods,
deck.compactCardIds,
selectedIds,
selectionShowcaseActive,
compactLimit,
],
);
const compactIdSet = useMemo(
() => new Set(compactCardIds),
[compactCardIds],
);
const sampleCards = useMemo(
() =>
rankedMethods.map((entry) => ({
rankedMethods.map((entry) => {
const recommendation = deck.recommendedById.get(entry.id);
const whyLine =
recommendation && recommendation.tag !== "everyRuleHas"
? formatRecommendationWhyLine(recommendation.reason, answers, copy)
: "";
const alternativeId = recommendation?.alternative;
const alternativeMethod = alternativeId
? rankedMethods.find((method) => method.id === alternativeId) ??
methods.find((method) => method.id === alternativeId)
: undefined;
return {
id: entry.id,
label: entry.label,
supportText: entry.supportText,
compactSupportText:
whyLine && compactIdSet.has(entry.id) ? whyLine : undefined,
recommended: recommendedIds.has(entry.id),
})),
[rankedMethods, recommendedIds],
alternative:
alternativeMethod && compactIdSet.has(entry.id)
? { id: alternativeMethod.id, label: alternativeMethod.label }
: undefined,
};
}),
[
answers,
compactIdSet,
copy,
deck.recommendedById,
methods,
rankedMethods,
recommendedIds,
],
);
const methodById = useMemo(
@@ -92,6 +128,8 @@ export function useMethodCardDeckOrdering(
recommendedIds,
sampleCards,
methodById,
showDeckLine: !deck.fallback,
compactLimit,
recommendationsReady: true,
};
}
@@ -8,11 +8,11 @@
* ({@link CreateFlowTwoColumnSelectShell}); options are {@link CardStack} with
* `layout="singleStack"` and {@link Selection} size `l`.
*
* Card click opens the Figma create modal (node `20246-15829`) with three
* editable sections rendered by {@link CommunicationMethodEditFields}. The
* primary action is **Add Platform** for an unselected card and **Save** for a
* selected card. **Remove** is a danger footer button on selected cards (and
* remains in the kebab) via {@link removeMethodCardFromFacetSelection}.
* Card click opens Overlay / Modal / Card as a routing view of its parts
* ({@link MethodCardPolicyDialog}). The primary action is **Add to Rule** for
* an unselected card and **Save** for a selected card. **Deselect** is the
* footer on selected cards (**Remove** stays in the kebab) via
* {@link removeMethodCardFromFacetSelection}.
*/
import { useState, useCallback, useMemo, useRef } from "react";
@@ -24,9 +24,9 @@ import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CommunicationMethodEditFields } from "../../components/methodEditFields";
import { MethodCardPolicyDialog } from "../../components/MethodCardPolicyDialog";
import { useCommunicationPolicySteps } from "../../components/methodCardPolicySteps";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { communicationPresetFor, emptyCommunicationDetails } from "../../../../../lib/create/finalReviewChipPresets";
@@ -105,7 +105,8 @@ export function CommunicationMethodsScreen() {
[comm.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
const rec = m.create.customRule.recommendations;
const { sampleCards, compactCardIds, methodById, recommendationsReady, showDeckLine, compactLimit } =
useMethodCardDeckOrdering(
"communication",
mergedMethods,
@@ -188,10 +189,14 @@ export function CommunicationMethodsScreen() {
},
[markCreateFlowInteraction],
);
const communicationSteps = useCommunicationPolicySteps(
pendingDraft ?? emptyCommunicationDetails(),
handleDraftChange,
pendingCardId ? communicationPresetFor(pendingCardId) : undefined,
);
const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false;
@@ -710,34 +715,36 @@ export function CommunicationMethodsScreen() {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
hasMore={sampleCards.length > compactLimit}
toggleLabel={comm.page.seeAllLink}
compactRecommendedLimit={5}
compactRecommendedLimit={compactLimit}
compactCardIds={compactCardIds}
layout="singleStack"
deckLine={showDeckLine ? rec.deckLine : undefined}
searchPlaceholder={rec.searchPlaceholder}
alternativePrefix={rec.alternative}
onAlternativeSelect={handleCardClick}
className="w-full lg:h-full lg:min-h-0 lg:flex-1"
/>
)}
</div>
</CreateFlowTwoColumnSelectShell>
<Create
<MethodCardPolicyDialog
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
cardLabel={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
editing={isSelectedCardModal}
steps={communicationSteps}
onClose={handleCreateModalClose}
onConfirm={handleCreateModalPrimary}
onDeselect={
isSelectedCardModal ? handleRemoveSelectedFromModal : undefined
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={kebabMenuItems}
>
{pendingCardId && pendingDraft ? (
modalUsesWizardFieldBlocksBody ? (
{pendingCardId && modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
@@ -754,15 +761,8 @@ export function CommunicationMethodsScreen() {
: (next) => setDraftFieldBlocks(next)
}
/>
) : (
<CommunicationMethodEditFields
value={pendingDraft}
onChange={handleDraftChange}
generateDraft={communicationPresetFor(pendingCardId)}
/>
)
) : null}
</Create>
</MethodCardPolicyDialog>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
@@ -7,12 +7,9 @@
* Page chrome is the shared question-and-options shell
* (`CreateFlowTwoColumnSelectShell`); options are CardStack `singleStack`.
*
* Card click opens the Figma "Add Approach" create modal (node `20874-172292`)
* with four controls rendered by {@link ConflictManagementEditFields}: Core
* Principle, Applicable Scope (text area), Process Protocol, and Restoration
* & Fallbacks. The same field set is reused on `/create/final-review` — see
* `FinalReviewChipEditModal`. Confirm persists both the chip selection and
* any user edits as a `conflictManagementDetailsById[id]` override.
* Card click opens Overlay / Modal / Card as a routing view of its parts
* ({@link MethodCardPolicyDialog}). Confirm persists the chip selection and
* any edits as a `conflictManagementDetailsById[id]` override.
*/
import { useState, useCallback, useMemo, useRef } from "react";
@@ -24,9 +21,9 @@ import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { ConflictManagementEditFields } from "../../components/methodEditFields";
import { MethodCardPolicyDialog } from "../../components/MethodCardPolicyDialog";
import { useConflictPolicySteps } from "../../components/methodCardPolicySteps";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { conflictManagementPresetFor, emptyConflictManagementDetails } from "../../../../../lib/create/finalReviewChipPresets";
@@ -105,7 +102,8 @@ export function ConflictManagementScreen() {
[cm.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
const rec = m.create.customRule.recommendations;
const { sampleCards, compactCardIds, methodById, recommendationsReady, showDeckLine, compactLimit } =
useMethodCardDeckOrdering(
"conflictManagement",
mergedMethods,
@@ -192,10 +190,14 @@ export function ConflictManagementScreen() {
},
[markCreateFlowInteraction],
);
const conflictSteps = useConflictPolicySteps(
pendingDraft ?? emptyConflictManagementDetails(),
handleDraftChange,
pendingCardId ? conflictManagementPresetFor(pendingCardId) : undefined,
);
const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false;
@@ -714,34 +716,36 @@ export function ConflictManagementScreen() {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
hasMore={sampleCards.length > compactLimit}
toggleLabel={cm.page.seeAllLink}
compactRecommendedLimit={5}
compactRecommendedLimit={compactLimit}
compactCardIds={compactCardIds}
layout="singleStack"
deckLine={showDeckLine ? rec.deckLine : undefined}
searchPlaceholder={rec.searchPlaceholder}
alternativePrefix={rec.alternative}
onAlternativeSelect={handleCardClick}
className="w-full lg:h-full lg:min-h-0 lg:flex-1"
/>
)}
</div>
</CreateFlowTwoColumnSelectShell>
<Create
<MethodCardPolicyDialog
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
cardLabel={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
editing={isSelectedCardModal}
steps={conflictSteps}
onClose={handleCreateModalClose}
onConfirm={handleCreateModalPrimary}
onDeselect={
isSelectedCardModal ? handleRemoveSelectedFromModal : undefined
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={kebabMenuItems}
>
{pendingCardId && pendingDraft ? (
modalUsesWizardFieldBlocksBody ? (
{pendingCardId && modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
@@ -758,15 +762,8 @@ export function ConflictManagementScreen() {
: (next) => setDraftFieldBlocks(next)
}
/>
) : (
<ConflictManagementEditFields
value={pendingDraft}
onChange={handleDraftChange}
generateDraft={conflictManagementPresetFor(pendingCardId)}
/>
)
) : null}
</Create>
</MethodCardPolicyDialog>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
@@ -7,13 +7,9 @@
* Page chrome is the shared question-and-options shell
* (`CreateFlowTwoColumnSelectShell`); options are CardStack `singleStack`.
*
* Card click opens the Figma create modal (node `20858-13948`) with three
* editable sections rendered by {@link MembershipMethodEditFields}. The same
* field set is reused on `/create/final-review` — see `FinalReviewChipEditModal`.
* Confirm persists both the chip selection and any user edits as a
* `membershipMethodDetailsById[id]` override; section defaults come from
* `messages/en/create/customRule/membership.json` and will be replaced with
* DB-driven content.
* Card click opens Overlay / Modal / Card as a routing view of its parts
* ({@link MethodCardPolicyDialog}). Confirm persists the chip selection and
* any edits as a `membershipMethodDetailsById[id]` override.
*/
import { useState, useCallback, useMemo, useRef } from "react";
@@ -25,9 +21,9 @@ import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { MembershipMethodEditFields } from "../../components/methodEditFields";
import { MethodCardPolicyDialog } from "../../components/MethodCardPolicyDialog";
import { useMembershipPolicySteps } from "../../components/methodCardPolicySteps";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { membershipPresetFor, emptyMembershipDetails } from "../../../../../lib/create/finalReviewChipPresets";
@@ -106,7 +102,8 @@ export function MembershipMethodsScreen() {
[mem.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
const rec = m.create.customRule.recommendations;
const { sampleCards, compactCardIds, methodById, recommendationsReady, showDeckLine, compactLimit } =
useMethodCardDeckOrdering(
"membership",
mergedMethods,
@@ -189,10 +186,14 @@ export function MembershipMethodsScreen() {
},
[markCreateFlowInteraction],
);
const membershipSteps = useMembershipPolicySteps(
pendingDraft ?? emptyMembershipDetails(),
handleDraftChange,
pendingCardId ? membershipPresetFor(pendingCardId) : undefined,
);
const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false;
@@ -707,34 +708,36 @@ export function MembershipMethodsScreen() {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
hasMore={sampleCards.length > compactLimit}
toggleLabel={mem.page.seeAllLink}
compactRecommendedLimit={5}
compactRecommendedLimit={compactLimit}
compactCardIds={compactCardIds}
layout="singleStack"
deckLine={showDeckLine ? rec.deckLine : undefined}
searchPlaceholder={rec.searchPlaceholder}
alternativePrefix={rec.alternative}
onAlternativeSelect={handleCardClick}
className="w-full lg:h-full lg:min-h-0 lg:flex-1"
/>
)}
</div>
</CreateFlowTwoColumnSelectShell>
<Create
<MethodCardPolicyDialog
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
cardLabel={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
editing={isSelectedCardModal}
steps={membershipSteps}
onClose={handleCreateModalClose}
onConfirm={handleCreateModalPrimary}
onDeselect={
isSelectedCardModal ? handleRemoveSelectedFromModal : undefined
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={kebabMenuItems}
>
{pendingCardId && pendingDraft ? (
modalUsesWizardFieldBlocksBody ? (
{pendingCardId && modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
@@ -751,15 +754,8 @@ export function MembershipMethodsScreen() {
: (next) => setDraftFieldBlocks(next)
}
/>
) : (
<MembershipMethodEditFields
value={pendingDraft}
onChange={handleDraftChange}
generateDraft={membershipPresetFor(pendingCardId)}
/>
)
) : null}
</Create>
</MethodCardPolicyDialog>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard}
@@ -7,20 +7,17 @@
* Layout matches {@link CreateFlowTwoColumnSelectShell}: one column below `lg` (1024px), two columns
* at `lg+` with a scrollable rail — same breakpoint and height chain as select steps, distinct content.
*
* Card click opens the Figma "Add Approach" create modal (node `20870-72155`) with five controls
* rendered by {@link DecisionApproachEditFields}: Core Principle, Applicable Scope, Step-by-Step
* Instructions, Consensus Level, and Objections & Deadlocks. The same field set is reused on
* `/create/final-review` — see `FinalReviewChipEditModal`. Confirm persists both the chip
* selection and any user edits as a `decisionApproachDetailsById[id]` override; section
* defaults come from `messages/en/create/customRule/decisionApproaches.json` and will be
* replaced with DB-driven content.
* Card click opens Overlay / Modal / Card as a routing view of its parts
* ({@link MethodCardPolicyDialog}). Confirm persists the chip selection and
* any edits as a `decisionApproachDetailsById[id]` override.
*/
import { useState, useCallback, useMemo, useRef } from "react";
import CardStack from "../../../../components/cards/CardStack";
import HeaderLockup from "../../../../components/type/HeaderLockup";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { MethodCardPolicyDialog } from "../../components/MethodCardPolicyDialog";
import { useDecisionApproachPolicySteps } from "../../components/methodCardPolicySteps";
import InfoMessageBox from "../../../../components/controls/InfoMessageBox";
import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMessageBox/InfoMessageBox.types";
import { useMessages } from "../../../../contexts/MessagesContext";
@@ -30,7 +27,6 @@ import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { DecisionApproachEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { decisionApproachPresetFor, emptyDecisionApproachTextFields } from "../../../../../lib/create/finalReviewChipPresets";
@@ -130,7 +126,8 @@ export function DecisionApproachesScreen() {
[da.methods, selectedIds, state.customMethodCardMetaById],
);
const { sampleCards, compactCardIds, methodById, recommendationsReady } =
const rec = m.create.customRule.recommendations;
const { sampleCards, compactCardIds, methodById, recommendationsReady, showDeckLine, compactLimit } =
useMethodCardDeckOrdering(
"decisionApproaches",
mergedMethods,
@@ -237,10 +234,17 @@ export function DecisionApproachesScreen() {
},
[markCreateFlowInteraction],
);
const decisionSteps = useDecisionApproachPolicySteps(
pendingDraft ??
emptyDecisionApproachTextFields(
decisionApproachPresetFor(pendingCardId ?? ""),
),
handleDraftChange,
pendingCardId ? decisionApproachPresetFor(pendingCardId) : undefined,
);
const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId);
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false;
@@ -776,7 +780,7 @@ export function DecisionApproachesScreen() {
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
hasMore={sampleCards.length > compactLimit}
toggleLabel={da.cardStack.toggleSeeAll}
showLessLabel={da.cardStack.toggleShowLess}
title=""
@@ -794,32 +798,34 @@ export function DecisionApproachesScreen() {
)
}
layout="singleStack"
compactRecommendedLimit={5}
compactRecommendedLimit={compactLimit}
compactCardIds={compactCardIds}
deckLine={showDeckLine ? rec.deckLine : undefined}
searchPlaceholder={rec.searchPlaceholder}
alternativePrefix={rec.alternative}
onAlternativeSelect={handleCardSelect}
className="w-full lg:h-full lg:min-h-0 lg:flex-1"
headerLockupSize={mdUp ? "L" : "M"}
/>
)}
</div>
<Create
<MethodCardPolicyDialog
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
cardLabel={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
showNextButton={showMethodModalPrimary}
showRemoveButton={isSelectedCardModal}
onRemove={handleRemoveSelectedFromModal}
backdropVariant="blurredYellow"
editing={isSelectedCardModal}
steps={decisionSteps}
onClose={handleCreateModalClose}
onConfirm={handleCreateModalPrimary}
onDeselect={
isSelectedCardModal ? handleRemoveSelectedFromModal : undefined
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={kebabMenuItems}
>
{pendingCardId && pendingDraft ? (
modalUsesWizardFieldBlocksBody ? (
{pendingCardId && modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody
cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById}
@@ -836,15 +842,8 @@ export function DecisionApproachesScreen() {
: (next) => setDraftFieldBlocks(next)
}
/>
) : (
<DecisionApproachEditFields
value={pendingDraft}
onChange={handleDraftChange}
generateDraft={decisionApproachPresetFor(pendingCardId)}
/>
)
) : null}
</Create>
</MethodCardPolicyDialog>
</CreateFlowTwoColumnSelectShell>
<CustomMethodCardWizard
isOpen={addCustomWizardOpen}
@@ -1,6 +1,6 @@
"use client";
import { memo, useCallback, useState } from "react";
import { memo, useCallback, useEffect, useState } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { CardStackView } from "./CardStack.view";
import type { CardStackProps } from "./CardStack.types";
@@ -29,7 +29,7 @@ const CardStackContainer = memo<CardStackProps>(
title = "",
description = "",
layout = "default",
compactRecommendedLimit = 5,
compactRecommendedLimit = 7,
compactCardIds,
compactDesktopLayout: compactDesktopLayoutProp = "grid",
headerLockupSize,
@@ -39,16 +39,25 @@ const CardStackContainer = memo<CardStackProps>(
addCardLabel = "",
addCardAriaLabel = "",
onAddCard,
deckLine = "",
searchPlaceholder = "",
alternativePrefix = "",
onAlternativeSelect,
}) => {
const t = useTranslation("controlsChrome");
const [internalExpanded, setInternalExpanded] = useState(false);
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(
[],
);
const [searchQuery, setSearchQuery] = useState("");
const expanded =
controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
useEffect(() => {
if (!expanded) setSearchQuery("");
}, [expanded]);
const handleToggleExpand = useCallback(() => {
if (controlledOnToggleExpand) {
controlledOnToggleExpand();
@@ -102,6 +111,12 @@ const CardStackContainer = memo<CardStackProps>(
addCardLabel={addCardLabel}
addCardAriaLabel={addCardAriaLabel}
onAddCard={onAddCard}
deckLine={deckLine}
searchPlaceholder={searchPlaceholder}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
alternativePrefix={alternativePrefix}
onAlternativeSelect={onAlternativeSelect}
/>
);
},
@@ -6,6 +6,10 @@ export interface CardStackItem {
label: string;
supportText?: string;
recommended?: boolean;
/** Compact-seven why line; See all keeps `supportText`. */
compactSupportText?: string;
/** One alternative per job, shown under the compact card. */
alternative?: { id: string; label: string };
}
export interface CardStackProps {
@@ -23,9 +27,16 @@ export interface CardStackProps {
/** "default" = compact grid/column + expanded 2-col 142px tiles; "singleStack" = always one column, expand shows more in same stack */
layout?: "default" | "singleStack";
/**
* Max recommended cards in compact (non-expanded) mode. Default 5; Figma compact stack uses 3.
* Max recommended cards in compact (non-expanded) mode. Default 7.
*/
compactRecommendedLimit?: number;
/** Line above the compact seven, in place of a tag on every card. */
deckLine?: string;
/** See-all search field placeholder. */
searchPlaceholder?: string;
/** Prefix for the compact alternative row. */
alternativePrefix?: string;
onAlternativeSelect?: (id: string) => void;
/**
* Optional explicit list of card ids to render in the compact slot, in
* order. When provided, this overrides the default
@@ -78,4 +89,10 @@ export interface CardStackViewProps {
addCardLabel: string;
addCardAriaLabel: string;
onAddCard?: () => void;
deckLine: string;
searchPlaceholder: string;
searchQuery: string;
onSearchQueryChange: (value: string) => void;
alternativePrefix: string;
onAlternativeSelect?: (id: string) => void;
}
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
import HeaderLockup from "../../type/HeaderLockup";
import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup.types";
import Selection from "../Selection";
import type { CardStackViewProps } from "./CardStack.types";
import type { CardStackItem, CardStackViewProps } from "./CardStack.types";
/** Figma Card / CardSelection on Compact + Expanded Card Stack (`281×142`). */
const FIGMA_CARD_SELECTION_TILE_CLASS =
@@ -38,6 +38,87 @@ function CardStackHeaderLockup({
);
}
function matchesSearch(item: CardStackItem, query: string): boolean {
if (!query) return true;
const haystack = `${item.label} ${item.supportText ?? ""}`.toLowerCase();
return haystack.includes(query.toLowerCase());
}
function SearchGlyph() {
return (
<svg
aria-hidden
viewBox="0 0 24 24"
className="pointer-events-none absolute right-3 top-1/2 size-6 -translate-y-1/2 text-[var(--color-content-default-tertiary)]"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.5" />
<path
d="M20 20l-3.5-3.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
function StackCard({
item,
expanded,
selected,
size,
className,
onSelect,
onAlternativeSelect,
alternativePrefix,
}: {
item: CardStackItem;
expanded: boolean;
selected: boolean;
size: "m" | "l";
className?: string;
onSelect: (id: string) => void;
onAlternativeSelect?: (id: string) => void;
alternativePrefix: string;
}) {
const supportText =
!expanded && !selected && item.compactSupportText
? item.compactSupportText
: item.supportText;
const recommended = Boolean(expanded && item.recommended);
const alternative =
!expanded && !selected && item.alternative && onAlternativeSelect ? (
<p className="w-full text-x-small-paragraph text-[var(--color-content-invert-tertiary)]">
{alternativePrefix}{" "}
<button
type="button"
className="underline text-[var(--color-content-invert-primary)]"
onClick={() => onAlternativeSelect(item.alternative!.id)}
>
{item.alternative.label}
</button>
</p>
) : null;
return (
<div className="flex w-full flex-col gap-1">
<Selection
id={item.id}
label={item.label}
supportText={supportText}
recommended={recommended}
selected={selected}
size={size}
showInfoIcon={false}
className={className}
onClick={() => onSelect(item.id)}
/>
{alternative}
</div>
);
}
function CardStackAddCardButton({
label,
ariaLabel,
@@ -98,6 +179,12 @@ export function CardStackView({
addCardLabel,
addCardAriaLabel,
onAddCard,
deckLine,
searchPlaceholder,
searchQuery,
onSearchQueryChange,
alternativePrefix,
onAlternativeSelect,
}: CardStackViewProps) {
const addTile =
showAddCard && onAddCard && addCardLabel.length > 0 ? (
@@ -130,7 +217,9 @@ export function CardStackView({
// At lg+ the cards scroll inside this column and the See all / Show less
// control stays pinned below (Figma Flow — Right Rail).
if (layout === "singleStack") {
const displayedCards = expanded ? cards : compactCards;
const displayedCards = (expanded ? cards : compactCards).filter((item) =>
expanded ? matchesSearch(item, searchQuery) : true,
);
return (
<div
className={`flex w-full min-w-0 flex-col gap-6 lg:h-full lg:max-h-full lg:min-h-0 lg:flex-1 lg:overflow-hidden ${className}`}
@@ -142,18 +231,35 @@ export function CardStackView({
size={lockupSize}
wrapperClassName="min-w-0 shrink-0"
/>
{!expanded && deckLine ? (
<p className="shrink-0 text-x-small-paragraph text-[var(--color-content-default-tertiary)]">
{deckLine}
</p>
) : null}
{expanded && searchPlaceholder ? (
<div className="relative w-full shrink-0">
<input
type="search"
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
className="h-10 w-full rounded-[var(--radius-200)] border border-[var(--color-border-default-control)] bg-[var(--color-surface-default-secondary)] px-3 py-2.5 pr-11 text-medium-paragraph text-[var(--color-content-default-primary)] placeholder:text-[var(--color-content-default-tertiary)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)]"
/>
<SearchGlyph />
</div>
) : null}
<div className="flex w-full min-w-0 flex-col gap-2 lg:min-h-0 lg:flex-1 lg:overflow-y-auto scrollbar-design">
{displayedCards.map((item) => (
<Selection
<StackCard
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
item={item}
expanded={expanded}
selected={isSelected(item.id)}
size="l"
showInfoIcon={false}
onClick={() => onCardSelect(item.id)}
onSelect={onCardSelect}
onAlternativeSelect={onAlternativeSelect}
alternativePrefix={alternativePrefix}
/>
))}
{addTile}
+1 -1
View File
@@ -72,7 +72,7 @@ function chipSurfaceStyle(
return paint(
"transparent",
"var(--color-content-default-brand-primary)",
"var(--color-border-default-control)",
"var(--color-content-default-tertiary)",
);
}
+12 -2
View File
@@ -95,14 +95,24 @@ export function stringArraysEqual(
return a.every((value, index) => value === b[index]);
}
export function decisionApproachDefaultScopeChips(): string[] {
const chips = (
decisionApproachesMessages as { defaultScopeChips?: unknown }
).defaultScopeChips;
if (!Array.isArray(chips)) return [];
return chips.filter((value): value is string => typeof value === "string");
}
/**
* Offer the four sidebar labels as Applicable Scope chips without persisting
* them onto `applicableScope` (unchecked keys must not publish as defaults).
* Offer the signed-off deck chips, then this card's scopes, then the coverage
* checklist labels. Unchecked extras are display-only and must not publish
* as defaults.
*/
export function withDecisionApproachKeyResourceScopes(
scopes: readonly string[],
): string[] {
return uniquePreserveOrder([
...decisionApproachDefaultScopeChips(),
...scopes,
...decisionApproachKeyResourceLabels(),
]);
+73
View File
@@ -0,0 +1,73 @@
import type { GroupAnswers, RecommendationReason, TraitKey } from "./deckTypes";
export type RecommendationWhyCopy = {
why: {
fit: string;
fitWithAnswer: string;
opened: string;
added: string;
};
group: {
kindAndSize: string;
kindOnly: string;
sizeOnly: string;
kind: Record<string, string>;
size: Record<string, string>;
};
answerThirdPerson: Record<string, string>;
answerYouSaid: Record<string, string>;
};
export function formatGroupPhrase(
answers: GroupAnswers,
copy: RecommendationWhyCopy,
): string {
const kind = answers.mainKind ? copy.group.kind[answers.mainKind] : "";
const size = answers.size ? copy.group.size[answers.size] : "";
if (kind && size) {
return copy.group.kindAndSize
.replace("{kind}", kind)
.replace("{size}", size);
}
if (kind) return copy.group.kindOnly.replace("{kind}", kind);
if (size) return copy.group.sizeOnly.replace("{size}", size);
return "";
}
function answerPhrase(
key: TraitKey,
phrases: Record<string, string>,
): string {
return phrases[key] ?? key;
}
/**
* Why line for a recommended card in the compact seven.
* Near-universal cards keep their catalog description instead.
*/
export function formatRecommendationWhyLine(
reason: RecommendationReason,
answers: GroupAnswers,
copy: RecommendationWhyCopy,
): string {
if (reason.kind === "added") {
return copy.why.added.replace(
"{answer}",
answerPhrase(reason.answer, copy.answerYouSaid),
);
}
if (reason.kind === "opened") {
return copy.why.opened.replace(
"{answer}",
answerPhrase(reason.answer, copy.answerYouSaid),
);
}
const group = formatGroupPhrase(answers, copy);
if (!group) return "";
if (reason.answer) {
return copy.why.fitWithAnswer
.replace("{group}", group)
.replace("{answer}", answerPhrase(reason.answer, copy.answerThirdPerson));
}
return copy.why.fit.replace("{group}", group);
}
@@ -0,0 +1,13 @@
{
"_comment": "Shared Overlay / Modal / Card chrome for the four decks (Figma 24973:1852, routing view 25181:55334).",
"addToRule": "Add to Rule",
"deselect": "Deselect",
"cancel": "Cancel",
"save": "Save",
"stepBadge": "Step {n}",
"emptyStepDescription": "Write this part of the policy.",
"scopeEmptyDescription": "Choose which decisions this card covers.",
"filledHint": "These are your community's words. Change anything that does not fit.",
"decreaseConsensusLevel": "Decrease consensus level",
"increaseConsensusLevel": "Increase consensus level"
}
@@ -68,6 +68,12 @@
"noSetShare": "No set share"
},
"scopeAddButtonLabel": "Add a scope",
"defaultScopeChips": [
"Changing the Rule",
"Money",
"Projects",
"Asking someone to leave"
],
"methods": [
{
"id": "decision-speeds",
@@ -1,6 +1,7 @@
{
"_comment": "Create flow, recommendation lines on each deck (Figma CR-291, deck seven board 24716:27074 and card treatments 24717:27255). Draft copy. Dani signs off the why lines and the alternative row.",
"deckLine": "Recommended for your group",
"_comment": "Create flow, recommendation lines on each deck (Figma deck seven board 25019:114405 and card treatments 25019:114480). Draft copy. Dani signs off the why lines and the alternative row.",
"deckLine": "Recommended for your community",
"searchPlaceholder": "Search this deck",
"tags": {
"recommended": "RECOMMENDED",
"everyRuleHas": "Every Rule has"
@@ -47,5 +48,5 @@
"records": "you keep information about the people you serve",
"risky": "your work carries physical risk, or needs a license, permit or inspection"
},
"alternative": "Another way groups do this"
"alternative": "Another way communities do this:"
}
+4
View File
@@ -59,6 +59,8 @@ import createDecisionApproaches from "./create/customRule/decisionApproaches.jso
import createConflictManagement from "./create/customRule/conflictManagement.json";
import createCustomMethodCardWizard from "./create/customRule/customMethodCardWizard.json";
import createModalKebabMenu from "./create/customRule/modalKebabMenu.json";
import createCardDialog from "./create/customRule/cardDialog.json";
import createRecommendations from "./create/customRule/recommendations.json";
// create – stage 3: reviewAndComplete
import createConfirmStakeholders from "./create/reviewAndComplete/confirmStakeholders.json";
@@ -133,6 +135,8 @@ export default {
conflictManagement: createConflictManagement,
customMethodCardWizard: createCustomMethodCardWizard,
modalKebabMenu: createModalKebabMenu,
cardDialog: createCardDialog,
recommendations: createRecommendations,
},
reviewAndComplete: {
confirmStakeholders: createConfirmStakeholders,
+1 -1
View File
@@ -54,7 +54,7 @@ describe("Chip", () => {
expect(chip.style.color).toContain("--color-content-default-brand-primary");
expect(chip.style.backgroundColor).toBe("transparent");
expect(chip.style.borderColor).toContain(
"--color-border-default-control",
"--color-content-default-tertiary",
);
});
@@ -62,6 +62,27 @@ function ScreenWithStateProbe({
* snapping back to preset copy. See {@link CommunicationMethodEditFields}
* and `buildPublishPayload` for the read side.
*/
const COMMUNICATION_STEPS = [
"Core principle and scope",
"How it works",
"Norms",
] as const;
function openPolicyStep(dialog: HTMLElement, name: string) {
fireEvent.click(within(dialog).getByRole("button", { name }));
}
function generateAllCommunicationSteps(dialog: HTMLElement) {
for (const name of COMMUNICATION_STEPS) {
openPolicyStep(dialog, name);
const generate = within(dialog).queryByRole("button", {
name: "Generate draft",
});
if (generate) fireEvent.click(generate);
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
}
}
describe("CommunicationMethodsScreen — Add card persistence", () => {
it("starts empty and Generate draft inserts the catalog preset", async () => {
let latest: CreateFlowState = {};
@@ -79,24 +100,24 @@ describe("CommunicationMethodsScreen — Add card persistence", () => {
const dialog = await screen.findByRole("dialog");
expect(within(dialog).queryByAltText("Help")).not.toBeInTheDocument();
const textboxes = within(dialog).getAllByRole("textbox");
expect(textboxes.length).toBe(3);
const corePrincipleField = textboxes[0] as HTMLTextAreaElement;
openPolicyStep(dialog, "Core principle and scope");
const corePrincipleField = within(dialog).getByRole(
"textbox",
) as HTMLTextAreaElement;
expect(corePrincipleField.value).toBe("");
const generateButtons = within(dialog).getAllByRole("button", {
name: "Generate draft",
});
expect(generateButtons.length).toBe(3);
fireEvent.click(generateButtons[0]);
fireEvent.click(
within(dialog).getByRole("button", { name: "Generate draft" }),
);
expect(corePrincipleField.value).toBe(
communicationPresetFor("signal").corePrinciple,
);
fireEvent.change(corePrincipleField, { target: { value: "Custom principle" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
fireEvent.click(
within(dialog).getByRole("button", {
name: "Add card",
name: "Add to Rule",
}),
);
@@ -160,13 +181,11 @@ describe("CommunicationMethodsScreen — Add card persistence", () => {
screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0],
);
const dialog = await screen.findByRole("dialog");
const textareas = within(dialog).getAllByRole(
"textbox",
) as HTMLTextAreaElement[];
expect(textareas.length).toBe(3);
expect(textareas[0].value).toBe("Saved principle");
expect(textareas[1].value).toBe("Saved logistics");
expect(textareas[2].value).toBe("Saved coc");
expect(within(dialog).getByText("Saved principle")).toBeInTheDocument();
expect(within(dialog).getByText("Saved logistics")).toBeInTheDocument();
expect(within(dialog).getByText("Saved coc")).toBeInTheDocument();
openPolicyStep(dialog, "Core principle and scope");
expect(within(dialog).getByRole("textbox")).toHaveValue("Saved principle");
});
it("persists edits and closes when Save is clicked on an already-selected platform", async () => {
@@ -193,10 +212,11 @@ describe("CommunicationMethodsScreen — Add card persistence", () => {
screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0],
);
const dialog = await screen.findByRole("dialog");
const textareas = within(dialog).getAllByRole(
"textbox",
) as HTMLTextAreaElement[];
fireEvent.change(textareas[0], { target: { value: "Edited principle" } });
openPolicyStep(dialog, "Core principle and scope");
fireEvent.change(within(dialog).getByRole("textbox"), {
target: { value: "Edited principle" },
});
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => {
@@ -239,14 +259,14 @@ describe("CommunicationMethodsScreen — Add card persistence", () => {
})[0],
);
const dialog = await screen.findByRole("dialog");
const textareas = within(dialog).getAllByRole(
"textbox",
) as HTMLTextAreaElement[];
expect(textareas).toHaveLength(3);
expect(textareas[0].value).toBe(details.corePrinciple);
expect(textareas[1].value).toBe(details.logisticsAdmin);
expect(textareas[2].value).toBe(details.codeOfConduct);
expect(within(dialog).queryByText(noFieldsHint)).not.toBeInTheDocument();
expect(within(dialog).getByText(details.corePrinciple)).toBeInTheDocument();
expect(within(dialog).getByText(details.logisticsAdmin)).toBeInTheDocument();
expect(within(dialog).getByText(details.codeOfConduct)).toBeInTheDocument();
openPolicyStep(dialog, "Core principle and scope");
expect(within(dialog).getByRole("textbox")).toHaveValue(
details.corePrinciple,
);
});
it("opens meaning fields editable without Customize", async () => {
@@ -262,14 +282,12 @@ describe("CommunicationMethodsScreen — Add card persistence", () => {
screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0],
);
const dialog = await screen.findByRole("dialog");
const textareas = within(dialog).getAllByRole(
"textbox",
) as HTMLTextAreaElement[];
expect(textareas[0]).not.toBeDisabled();
fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
expect(
screen.getByRole("menuitem", { name: "Customize" }),
).toBeInTheDocument();
openPolicyStep(dialog, "Core principle and scope");
expect(within(dialog).getByRole("textbox")).not.toBeDisabled();
});
it("closing the wizard without Finalize leaves the card modal and does not persist", async () => {
@@ -347,11 +365,12 @@ describe("CommunicationMethodsScreen — Add card persistence", () => {
screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0],
);
const dialog = await screen.findByRole("dialog");
const textboxes = within(dialog).getAllByRole(
"textbox",
) as HTMLTextAreaElement[];
fireEvent.change(textboxes[0], { target: { value: "Edited principle" } });
openPolicyStep(dialog, "Core principle and scope");
fireEvent.change(within(dialog).getByRole("textbox"), {
target: { value: "Edited principle" },
});
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.keyDown(document, { key: "Escape" });
const keepEditing = await screen.findByRole("button", {
name: "Keep editing",
@@ -390,10 +409,11 @@ describe("CommunicationMethodsScreen — Add card persistence", () => {
screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0],
);
const dialog = await screen.findByRole("dialog");
const textboxes = within(dialog).getAllByRole(
"textbox",
) as HTMLTextAreaElement[];
fireEvent.change(textboxes[0], { target: { value: "Edited principle" } });
openPolicyStep(dialog, "Core principle and scope");
fireEvent.change(within(dialog).getByRole("textbox"), {
target: { value: "Edited principle" },
});
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.keyDown(document, { key: "Escape" });
await confirmDiscardCustomizeEdits();
@@ -473,11 +493,7 @@ describe("CommunicationMethodsScreen — Add card persistence", () => {
screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0],
);
const dialog = await screen.findByRole("dialog");
for (const button of within(dialog).getAllByRole("button", {
name: "Generate draft",
})) {
fireEvent.click(button);
}
generateAllCommunicationSteps(dialog);
fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" }));
@@ -619,10 +635,10 @@ describe("CommunicationMethodsScreen — Add card persistence", () => {
screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0],
);
const dialog = await screen.findByRole("dialog");
const textboxes = within(dialog).getAllByRole(
"textbox",
) as HTMLTextAreaElement[];
fireEvent.change(textboxes[0], { target: { value: "Unsaved principle" } });
openPolicyStep(dialog, "Core principle and scope");
fireEvent.change(within(dialog).getByRole("textbox"), {
target: { value: "Unsaved principle" },
});
expect(dispatchBeforeUnload()).toBe(true);
});
@@ -22,13 +22,8 @@ describe("ConflictManagementScreen", () => {
})[0],
);
const dialog = await screen.findByRole("dialog");
const fields = within(dialog).getAllByRole("textbox");
expect(fields.length).toBeGreaterThan(0);
for (const field of fields) {
expect(field).toBeEnabled();
}
expect(
within(dialog).queryByRole("button", { name: "Remove" }),
within(dialog).queryByRole("button", { name: "Deselect" }),
).not.toBeInTheDocument();
fireEvent.click(
within(dialog).getByRole("button", { name: "More options" }),
@@ -36,5 +31,13 @@ describe("ConflictManagementScreen", () => {
expect(
screen.getByRole("menuitem", { name: "Customize" }),
).toBeInTheDocument();
fireEvent.click(
within(dialog).getByRole("button", { name: "Core principle" }),
);
const fields = within(dialog).getAllByRole("textbox");
expect(fields.length).toBeGreaterThan(0);
for (const field of fields) {
expect(field).toBeEnabled();
}
});
});
@@ -22,13 +22,8 @@ describe("MembershipMethodsScreen", () => {
})[0],
);
const dialog = await screen.findByRole("dialog");
const fields = within(dialog).getAllByRole("textbox");
expect(fields.length).toBeGreaterThan(0);
for (const field of fields) {
expect(field).toBeEnabled();
}
expect(
within(dialog).queryByRole("button", { name: "Remove" }),
within(dialog).queryByRole("button", { name: "Deselect" }),
).not.toBeInTheDocument();
fireEvent.click(
within(dialog).getByRole("button", { name: "More options" }),
@@ -36,5 +31,13 @@ describe("MembershipMethodsScreen", () => {
expect(
screen.getByRole("menuitem", { name: "Customize" }),
).toBeInTheDocument();
fireEvent.click(
within(dialog).getByRole("button", { name: "Who it covers and why" }),
);
const fields = within(dialog).getAllByRole("textbox");
expect(fields.length).toBeGreaterThan(0);
for (const field of fields) {
expect(field).toBeEnabled();
}
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import {
renderWithProviders as render,
screen,
within,
} from "../utils/test-utils";
import { MethodCardPolicyDialog } from "../../app/(app)/create/components/MethodCardPolicyDialog";
import type { MethodCardPolicyStep } from "../../app/(app)/create/components/MethodCardPolicyDialog";
const steps: MethodCardPolicyStep[] = [
{
id: "core",
title: "Core principle and scope",
emptyDescription: "Write this part of the policy.",
done: false,
summary: "",
renderEditor: () => (
<textarea aria-label="Core principle and scope" defaultValue="" />
),
},
];
describe("MethodCardPolicyDialog", () => {
it("routes through a step then Add to Rule", () => {
const onConfirm = vi.fn();
render(
<MethodCardPolicyDialog
isOpen
cardLabel="Signal"
editing={false}
steps={steps}
onClose={() => undefined}
onConfirm={onConfirm}
/>,
);
const dialog = screen.getByRole("dialog");
expect(
within(dialog).getByRole("button", { name: "Add to Rule" }),
).toBeInTheDocument();
fireEvent.click(
within(dialog).getByRole("button", { name: "Core principle and scope" }),
);
expect(
within(dialog).getByRole("textbox", { name: "Core principle and scope" }),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
expect(
within(dialog).getByRole("button", { name: "Add to Rule" }),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Add to Rule" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
});
+40 -15
View File
@@ -37,10 +37,10 @@ describe("Create flow communication-methods page", () => {
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(within(dialog).getByText("Signal")).toBeInTheDocument();
expect(within(dialog).getByText("Add card")).toBeInTheDocument();
expect(within(dialog).getByText("Add to Rule")).toBeInTheDocument();
});
test("re-opening a selected method shows Save and footer Remove; Remove stays in the kebab", async () => {
test("re-opening a selected method shows Save and footer Deselect; Remove stays in the kebab", async () => {
const user = userEvent.setup();
render(<CommunicationMethodsScreen />);
@@ -49,15 +49,15 @@ describe("Create flow communication-methods page", () => {
});
await user.click(signalCards[0]);
const dialog = screen.getByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: "Add card" }));
await user.click(within(dialog).getByRole("button", { name: "Add to Rule" }));
await user.click(signalCards[0]);
const dialogAgain = screen.getByRole("dialog");
expect(
within(dialogAgain).getByRole("button", { name: "Remove" }),
within(dialogAgain).getByRole("button", { name: "Deselect" }),
).toBeInTheDocument();
expect(
within(dialogAgain).queryByRole("button", { name: "Add card" }),
within(dialogAgain).queryByRole("button", { name: "Add to Rule" }),
).not.toBeInTheDocument();
expect(
within(dialogAgain).getByRole("button", { name: "Save" }),
@@ -67,7 +67,7 @@ describe("Create flow communication-methods page", () => {
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
});
test("Remove from the footer deselects the method", async () => {
test("Deselect from the footer deselects the method", async () => {
const user = userEvent.setup();
render(<CommunicationMethodsScreen />);
@@ -77,7 +77,7 @@ describe("Create flow communication-methods page", () => {
await user.click(signalCards[0]);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", {
name: "Add card",
name: "Add to Rule",
}),
);
@@ -85,7 +85,9 @@ describe("Create flow communication-methods page", () => {
await user.click(signalCards[0]);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }),
within(screen.getByRole("dialog")).getByRole("button", {
name: "Deselect",
}),
);
expect(signalCards[0]).not.toHaveTextContent("SELECTED");
@@ -101,7 +103,7 @@ describe("Create flow communication-methods page", () => {
await user.click(signalCards[0]);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", {
name: "Add card",
name: "Add to Rule",
}),
);
@@ -143,11 +145,13 @@ describe("Create flow communication-methods page", () => {
await user.click(signalCards[0]);
const dialog = screen.getByRole("dialog");
const textbox = within(dialog).getAllByRole("textbox")[0];
expect(textbox).not.toBeDisabled();
await user.click(within(dialog).getByRole("button", { name: "More options" }));
expect(screen.getByRole("menuitem", { name: "Customize" })).toBeInTheDocument();
await user.click(
within(dialog).getByRole("button", { name: "Core principle and scope" }),
);
const textbox = within(dialog).getAllByRole("textbox")[0];
expect(textbox).not.toBeDisabled();
});
test("meta-only custom policy opens with editable section fields", async () => {
@@ -167,14 +171,35 @@ describe("Create flow communication-methods page", () => {
screen.getAllByRole("button", { name: /My policy: Desc/ })[0],
);
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).queryByText("No custom fields yet."),
).not.toBeInTheDocument();
await user.click(
within(dialog).getByRole("button", { name: "Core principle and scope" }),
);
const textboxes = within(dialog).getAllByRole("textbox");
expect(textboxes.length).toBeGreaterThan(0);
for (const field of textboxes) {
expect(field).not.toBeDisabled();
}
});
test("compact stack shows seven cards and See all search", async () => {
const user = userEvent.setup();
render(<CommunicationMethodsScreen />);
const compactCards = screen
.getAllByRole("button")
.filter((el) => (el.getAttribute("aria-label") || "").includes(": "));
expect(compactCards).toHaveLength(7);
expect(screen.queryByText("RECOMMENDED")).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "See all communication cards" }),
);
expect(
within(dialog).queryByText("No custom fields yet."),
).not.toBeInTheDocument();
screen.getByRole("searchbox", { name: "Search this deck" }),
).toBeInTheDocument();
});
test("renders without error", () => {
@@ -419,7 +444,7 @@ describe("Create flow communication-methods page", () => {
const dupId = dupIds[0];
expect(latest.selectedCommunicationMethodIds).toEqual([CUSTOM_POLICY_ID]);
expect(
within(dialog).getByRole("button", { name: "Add card" }),
within(dialog).getByRole("button", { name: "Add to Rule" }),
).toBeInTheDocument();
expect(metaAfterDup[dupId].label.endsWith(" (copy)")).toBe(true);
expect(within(dialog).getByRole("heading", { level: 1 })).toHaveTextContent(
+85 -20
View File
@@ -185,7 +185,7 @@ describe("Create flow decision-approaches page", () => {
await user.click(screen.getByRole("button", { name: /^Consent \(sociocracy\):/ }));
await user.click(
within(await screen.findByRole("dialog")).getByRole("button", {
name: "Add card",
name: "Add to Rule",
}),
);
await waitFor(() => {
@@ -211,17 +211,20 @@ describe("Create flow decision-approaches page", () => {
}),
);
const dialog = await screen.findByRole("dialog");
const textboxes = within(dialog).getAllByRole("textbox");
expect(textboxes.length).toBeGreaterThan(0);
for (const field of textboxes) {
expect(field).not.toBeDisabled();
}
await user.click(
within(dialog).getByRole("button", { name: "More options" }),
);
expect(
screen.getByRole("menuitem", { name: "Customize" }),
).toBeInTheDocument();
await user.click(
within(dialog).getByRole("button", { name: "Core principle" }),
);
const textboxes = within(dialog).getAllByRole("textbox");
expect(textboxes.length).toBeGreaterThan(0);
for (const field of textboxes) {
expect(field).not.toBeDisabled();
}
});
test("clicking a card opens the create modal and confirming selects it", async () => {
@@ -237,14 +240,14 @@ describe("Create flow decision-approaches page", () => {
expect(dialog).toBeInTheDocument();
const confirmButton = within(dialog).getByRole("button", {
name: "Add card",
name: "Add to Rule",
});
await user.click(confirmButton);
expect(screen.getByText("SELECTED")).toBeInTheDocument();
});
test("re-opening a selected approach shows Save and footer Remove; Remove stays in the kebab", async () => {
test("re-opening a selected approach shows Save and footer Deselect; Remove stays in the kebab", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
@@ -255,17 +258,17 @@ describe("Create flow decision-approaches page", () => {
const dialog = await screen.findByRole("dialog");
await user.click(
within(dialog).getByRole("button", {
name: "Add card",
name: "Add to Rule",
}),
);
await user.click(card);
const dialogAgain = screen.getByRole("dialog");
expect(
within(dialogAgain).getByRole("button", { name: "Remove" }),
within(dialogAgain).getByRole("button", { name: "Deselect" }),
).toBeInTheDocument();
expect(
within(dialogAgain).queryByRole("button", { name: "Add card" }),
within(dialogAgain).queryByRole("button", { name: "Add to Rule" }),
).not.toBeInTheDocument();
expect(
within(dialogAgain).getByRole("button", { name: "Save" }),
@@ -275,7 +278,7 @@ describe("Create flow decision-approaches page", () => {
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
});
test("Remove from the footer deselects the approach", async () => {
test("Deselect from the footer deselects the approach", async () => {
const user = userEvent.setup();
render(<DecisionApproachesScreen />);
@@ -285,7 +288,7 @@ describe("Create flow decision-approaches page", () => {
await user.click(card);
await user.click(
within(await screen.findByRole("dialog")).getByRole("button", {
name: "Add card",
name: "Add to Rule",
}),
);
@@ -293,7 +296,9 @@ describe("Create flow decision-approaches page", () => {
await user.click(card);
await user.click(
within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }),
within(screen.getByRole("dialog")).getByRole("button", {
name: "Deselect",
}),
);
expect(card).not.toHaveTextContent("SELECTED");
@@ -309,17 +314,21 @@ describe("Create flow decision-approaches page", () => {
await user.click(card);
const dialog = await screen.findByRole("dialog");
await user.click(
within(dialog).getByRole("button", { name: "Add card" }),
within(dialog).getByRole("button", { name: "Add to Rule" }),
);
await user.click(card);
const dialogAgain = await screen.findByRole("dialog");
await user.click(
within(dialogAgain).getByRole("button", { name: "Core principle" }),
);
const principleField = within(dialogAgain).getByRole("textbox", {
name: /core principle/i,
});
await user.clear(principleField);
await user.type(principleField, "Edited principle");
await user.click(within(dialogAgain).getByRole("button", { name: "Save" }));
await user.click(within(dialogAgain).getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@@ -336,7 +345,7 @@ describe("Create flow decision-approaches page", () => {
await user.click(card);
await user.click(
within(await screen.findByRole("dialog")).getByRole("button", {
name: "Add card",
name: "Add to Rule",
}),
);
@@ -397,6 +406,14 @@ describe("Create flow decision-approaches page", () => {
}),
);
const dialog = await screen.findByRole("dialog");
await user.click(
within(dialog).getByRole("button", { name: "Applicable scope" }),
);
expect(
within(dialog).getByRole("button", {
name: "Select Changing the Rule",
}),
).toBeInTheDocument();
expect(
within(dialog).getByRole("button", {
name: "Select Changing this Rule",
@@ -431,6 +448,9 @@ describe("Create flow decision-approaches page", () => {
}),
);
const lazyDialog = await screen.findByRole("dialog");
await user.click(
within(lazyDialog).getByRole("button", { name: "Applicable scope" }),
);
expect(
within(lazyDialog).getByRole("button", {
name: "Select Shared money",
@@ -448,6 +468,9 @@ describe("Create flow decision-approaches page", () => {
}),
);
const lazyDialog = await screen.findByRole("dialog");
await user.click(
within(lazyDialog).getByRole("button", { name: "Applicable scope" }),
);
await user.click(
within(lazyDialog).getByRole("button", {
name: "Select Shared money",
@@ -457,7 +480,10 @@ describe("Create flow decision-approaches page", () => {
screen.getByRole("checkbox", { name: "Shared money" }),
).toBeChecked();
await user.click(
within(lazyDialog).getByRole("button", { name: "Add card" }),
within(lazyDialog).getByRole("button", { name: "Save" }),
);
await user.click(
within(lazyDialog).getByRole("button", { name: "Add to Rule" }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@@ -469,6 +495,9 @@ describe("Create flow decision-approaches page", () => {
}),
);
const doocracyDialog = await screen.findByRole("dialog");
await user.click(
within(doocracyDialog).getByRole("button", { name: "Applicable scope" }),
);
expect(
within(doocracyDialog).getByRole("button", {
name: "Select Shared money",
@@ -482,6 +511,9 @@ describe("Create flow decision-approaches page", () => {
expect(
screen.getByRole("checkbox", { name: "Shared money" }),
).not.toBeChecked();
await user.click(
within(doocracyDialog).getByRole("button", { name: "Save" }),
);
await user.click(
within(doocracyDialog).getByRole("button", { name: "Close" }),
);
@@ -499,6 +531,9 @@ describe("Create flow decision-approaches page", () => {
}),
);
const lazyAgain = await screen.findByRole("dialog");
await user.click(
within(lazyAgain).getByRole("button", { name: "Applicable scope" }),
);
expect(
within(lazyAgain).getByRole("button", {
name: "Deselect Shared money",
@@ -520,7 +555,7 @@ describe("Create flow decision-approaches page", () => {
);
const lazyDialog = await screen.findByRole("dialog");
await user.click(
within(lazyDialog).getByRole("button", { name: "Add card" }),
within(lazyDialog).getByRole("button", { name: "Add to Rule" }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@@ -532,6 +567,9 @@ describe("Create flow decision-approaches page", () => {
}),
);
const doocracyDialog = await screen.findByRole("dialog");
await user.click(
within(doocracyDialog).getByRole("button", { name: "Applicable scope" }),
);
await user.click(
screen.getByRole("checkbox", { name: "Shared money" }),
);
@@ -540,6 +578,9 @@ describe("Create flow decision-approaches page", () => {
name: "Deselect Shared money",
}),
).toBeInTheDocument();
await user.click(
within(doocracyDialog).getByRole("button", { name: "Save" }),
);
await user.click(
within(doocracyDialog).getByRole("button", { name: "Close" }),
);
@@ -554,6 +595,9 @@ describe("Create flow decision-approaches page", () => {
}),
);
const lazyAgain = await screen.findByRole("dialog");
await user.click(
within(lazyAgain).getByRole("button", { name: "Applicable scope" }),
);
expect(
within(lazyAgain).getByRole("button", {
name: "Select Shared money",
@@ -571,6 +615,9 @@ describe("Create flow decision-approaches page", () => {
}),
);
const lazyDialog = await screen.findByRole("dialog");
await user.click(
within(lazyDialog).getByRole("button", { name: "Applicable scope" }),
);
await user.click(
within(lazyDialog).getByRole("button", {
name: "Select Shared money",
@@ -580,7 +627,10 @@ describe("Create flow decision-approaches page", () => {
screen.getByRole("checkbox", { name: "Shared money" }),
).toBeChecked();
await user.click(
within(lazyDialog).getByRole("button", { name: "Add card" }),
within(lazyDialog).getByRole("button", { name: "Save" }),
);
await user.click(
within(lazyDialog).getByRole("button", { name: "Add to Rule" }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@@ -592,6 +642,9 @@ describe("Create flow decision-approaches page", () => {
}),
);
const doocracyDialog = await screen.findByRole("dialog");
await user.click(
within(doocracyDialog).getByRole("button", { name: "Applicable scope" }),
);
await user.click(
within(doocracyDialog).getByRole("button", {
name: "Select Big decisions",
@@ -609,7 +662,10 @@ describe("Create flow decision-approaches page", () => {
}),
).toBeInTheDocument();
await user.click(
within(doocracyDialog).getByRole("button", { name: "Add card" }),
within(doocracyDialog).getByRole("button", { name: "Save" }),
);
await user.click(
within(doocracyDialog).getByRole("button", { name: "Add to Rule" }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@@ -628,6 +684,9 @@ describe("Create flow decision-approaches page", () => {
}),
);
const lazyAgain = await screen.findByRole("dialog");
await user.click(
within(lazyAgain).getByRole("button", { name: "Applicable scope" }),
);
expect(
within(lazyAgain).getByRole("button", {
name: "Deselect Shared money",
@@ -638,6 +697,9 @@ describe("Create flow decision-approaches page", () => {
name: "Select Big decisions",
}),
).toBeInTheDocument();
await user.click(
within(lazyAgain).getByRole("button", { name: "Save" }),
);
await user.click(
within(lazyAgain).getByRole("button", { name: "Close" }),
);
@@ -651,6 +713,9 @@ describe("Create flow decision-approaches page", () => {
}),
);
const doocracyAgain = await screen.findByRole("dialog");
await user.click(
within(doocracyAgain).getByRole("button", { name: "Applicable scope" }),
);
expect(
within(doocracyAgain).getByRole("button", {
name: "Deselect Big decisions",
+109
View File
@@ -155,6 +155,115 @@ describe("CardStack Component", () => {
expect(screen.getAllByText("SELECTED").length).toBeGreaterThanOrEqual(1);
});
test("compact single-stack shows seven recommended cards and hides the tag", () => {
const cards = Array.from({ length: 8 }, (_, index) => ({
id: String(index),
label: `Card ${index}`,
supportText: `Desc ${index}`,
recommended: true,
}));
render(
<CardStack cards={cards} layout="singleStack" expanded={false} />,
);
expect(
screen.getAllByRole("button", { name: /Card \d: Desc \d/ }),
).toHaveLength(7);
expect(screen.queryByText("RECOMMENDED")).not.toBeInTheDocument();
});
test("See all search filters the expanded single-stack", () => {
render(
<CardStack
cards={SAMPLE_CARDS}
layout="singleStack"
expanded={true}
searchPlaceholder="Search this deck"
/>,
);
const search = screen.getByRole("searchbox", { name: "Search this deck" });
fireEvent.change(search, { target: { value: "Option B" } });
expect(
screen.getByRole("button", { name: "Option B: Description B" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Option A: Description A" }),
).not.toBeInTheDocument();
});
test("selected compact card keeps catalog copy and hides the alternative", () => {
render(
<CardStack
cards={[
{
id: "1",
label: "Option A",
supportText: "Catalog copy",
compactSupportText: "Fits a worker co-op",
recommended: true,
alternative: { id: "2", label: "Option B" },
},
]}
selectedIds={["1"]}
layout="singleStack"
alternativePrefix="Another way communities do this:"
/>,
);
expect(
screen.getByRole("button", { name: "Option A: Catalog copy" }),
).toBeInTheDocument();
expect(screen.queryByText(/Fits a worker co-op/)).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Option B" }),
).not.toBeInTheDocument();
});
test("unselected compact card shows the why line", () => {
render(
<CardStack
cards={[
{
id: "1",
label: "Option A",
supportText: "Catalog copy",
compactSupportText: "Fits a worker co-op",
recommended: true,
},
]}
layout="singleStack"
/>,
);
expect(
screen.getByRole("button", { name: "Option A: Fits a worker co-op" }),
).toBeInTheDocument();
});
test("compact alternative row opens the named card", () => {
const onAlternativeSelect = vi.fn();
render(
<CardStack
cards={[
{
id: "1",
label: "Option A",
supportText: "Description A",
recommended: true,
alternative: { id: "2", label: "Option B" },
},
]}
layout="singleStack"
alternativePrefix="Another way communities do this:"
onAlternativeSelect={onAlternativeSelect}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Option B" }));
expect(onAlternativeSelect).toHaveBeenCalledWith("2");
});
test("calls onCardSelect when re-clicking an already selected card", () => {
const onCardSelect = vi.fn();
render(
@@ -9,6 +9,7 @@ import {
selectedKeyResourceLabelsFromCheckedIds,
stringArraysEqual,
syncDecisionApproachKeyResourceDetails,
decisionApproachDefaultScopeChips,
withDecisionApproachKeyResourceScopes,
} from "../../lib/create/decisionApproachKeyResources";
import type { DecisionApproachDetailEntry } from "../../app/(app)/create/types";
@@ -158,10 +159,20 @@ describe("decisionApproachKeyResources", () => {
expect(decisionApproachKeyResourceLabels()).toHaveLength(7);
});
it("unions key-resource labels onto an existing scope list", () => {
it("unions signed-off default chips then key-resource labels onto an existing scope list", () => {
expect(decisionApproachDefaultScopeChips()).toEqual([
"Changing the Rule",
"Money",
"Projects",
"Asking someone to leave",
]);
expect(
withDecisionApproachKeyResourceScopes(["Daily Operations"]),
).toEqual([
"Changing the Rule",
"Money",
"Projects",
"Asking someone to leave",
"Daily Operations",
"Everyday decisions",
"Big decisions",
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { formatRecommendationWhyLine } from "../../lib/create/formatRecommendationWhyLine";
import type { GroupAnswers } from "../../lib/create/deckTypes";
import messages from "../../messages/en/index";
const copy = messages.create.customRule.recommendations;
const emptyAnswers: GroupAnswers = {
size: null,
mainKind: null,
otherKinds: [],
scale: [],
stage: null,
traits: {},
};
describe("formatRecommendationWhyLine", () => {
it("builds a fit line from kind and size", () => {
expect(
formatRecommendationWhyLine(
{ kind: "fit", matched: ["kind"] },
{ ...emptyAnswers, mainKind: "workersCoop", size: "sixToTwelve" },
copy,
),
).toBe("Fits a worker co-op of 6 to 12 people");
});
it("builds an added line from the setup answer", () => {
expect(
formatRecommendationWhyLine(
{ kind: "added", answer: "employer" },
emptyAnswers,
copy,
),
).toBe("Added because you said you pay some people to do your work");
});
});