Merge pull request 'Show seven cards on the four method decks and open each as a routing overlay of its parts' (#99) from adilallo/fix/CR-281-four-decks into main

Reviewed-on: #99
This commit was merged in pull request #99.
This commit is contained in:
an.di committed 2026-11-06 15:14:52 +00:00
commit cc878e3c19
28 files changed
+1334 -254

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, deckData,
recommendDeck, recommendDeck,
} from "../../../../lib/create/deckRecommend"; } 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). * Card-deck section ids served by `/api/create-flow/methods` (CR-88 §9.2).
@@ -143,8 +146,13 @@ export function deriveCompactCards(
compactCardIds: string[]; compactCardIds: string[];
recommendedIds: Set<string>; recommendedIds: Set<string>;
orderedIds: string[]; orderedIds: string[];
fallback: boolean;
recommendedById: Map<string, RecommendedCard>;
} { } {
const recommendation = recommendDeck(deckData(section), answers, { limit }); const recommendation = recommendDeck(deckData(section), answers, { limit });
const recommendedById = new Map(
recommendation.recommended.map((card) => [card.id, card]),
);
const orderedIds = recommendation.fallback const orderedIds = recommendation.fallback
? recommendation.seeAll ? recommendation.seeAll
: [ : [
@@ -156,6 +164,8 @@ export function deriveCompactCards(
compactCardIds: orderedIds.slice(0, limit), compactCardIds: orderedIds.slice(0, limit),
recommendedIds: new Set<string>(), recommendedIds: new Set<string>(),
orderedIds, orderedIds,
fallback: true,
recommendedById,
}; };
} }
return { return {
@@ -166,6 +176,8 @@ export function deriveCompactCards(
.map((card) => card.id), .map((card) => card.id),
), ),
orderedIds, orderedIds,
fallback: false,
recommendedById,
}; };
} }
@@ -2,12 +2,15 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { mergeCompactCardIdsWithPinnedSelected } from "../../../../lib/create/methodCardDisplayOrder"; import { mergeCompactCardIdsWithPinnedSelected } from "../../../../lib/create/methodCardDisplayOrder";
import { formatRecommendationWhyLine } from "../../../../lib/create/formatRecommendationWhyLine";
import { import {
deriveCompactCards, deriveCompactCards,
groupAnswersForRecommendations, groupAnswersForRecommendations,
type RecommendationSection, type RecommendationSection,
} from "./useFacetRecommendations"; } from "./useFacetRecommendations";
import { deckData } from "../../../../lib/create/deckRecommend";
import { useCreateFlow } from "../context/CreateFlowContext"; import { useCreateFlow } from "../context/CreateFlowContext";
import { useMessages } from "../../../contexts/MessagesContext";
type MethodEntry = { id: string; label: string; supportText: string }; 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 * Deck order and the compact stack come from the job rule. Compact shows
* show five cards; a deck screen passes seven. Selected cards still pin * at most seven recommended cards. Selected cards still pin into the stack.
* into the compact stack.
*/ */
export function useMethodCardDeckOrdering( export function useMethodCardDeckOrdering(
section: RecommendationSection, section: RecommendationSection,
@@ -35,13 +37,15 @@ export function useMethodCardDeckOrdering(
selectedIds: readonly string[], selectedIds: readonly string[],
) { ) {
const { state } = useCreateFlow(); const { state } = useCreateFlow();
const copy = useMessages().create.customRule.recommendations;
const compactLimit = deckData(section).config.displayLimit;
const answers = useMemo( const answers = useMemo(
() => groupAnswersForRecommendations(state), () => groupAnswersForRecommendations(state),
[state], [state],
); );
const deck = useMemo( const deck = useMemo(
() => deriveCompactCards(section, answers, 5), () => deriveCompactCards(section, answers, compactLimit),
[section, answers], [section, answers, compactLimit],
); );
const rankedMethods = useMemo( const rankedMethods = useMemo(
@@ -59,25 +63,57 @@ export function useMethodCardDeckOrdering(
deck.compactCardIds, deck.compactCardIds,
selectedIds, selectedIds,
selectionShowcaseActive, selectionShowcaseActive,
5, compactLimit,
), ),
[ [
rankedMethods, rankedMethods,
deck.compactCardIds, deck.compactCardIds,
selectedIds, selectedIds,
selectionShowcaseActive, selectionShowcaseActive,
compactLimit,
], ],
); );
const compactIdSet = useMemo(
() => new Set(compactCardIds),
[compactCardIds],
);
const sampleCards = useMemo( 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, id: entry.id,
label: entry.label, label: entry.label,
supportText: entry.supportText, supportText: entry.supportText,
compactSupportText:
whyLine && compactIdSet.has(entry.id) ? whyLine : undefined,
recommended: recommendedIds.has(entry.id), recommended: recommendedIds.has(entry.id),
})), alternative:
[rankedMethods, recommendedIds], alternativeMethod && compactIdSet.has(entry.id)
? { id: alternativeMethod.id, label: alternativeMethod.label }
: undefined,
};
}),
[
answers,
compactIdSet,
copy,
deck.recommendedById,
methods,
rankedMethods,
recommendedIds,
],
); );
const methodById = useMemo( const methodById = useMemo(
@@ -92,6 +128,8 @@ export function useMethodCardDeckOrdering(
recommendedIds, recommendedIds,
sampleCards, sampleCards,
methodById, methodById,
showDeckLine: !deck.fallback,
compactLimit,
recommendationsReady: true, recommendationsReady: true,
}; };
} }
@@ -8,11 +8,11 @@
* ({@link CreateFlowTwoColumnSelectShell}); options are {@link CardStack} with * ({@link CreateFlowTwoColumnSelectShell}); options are {@link CardStack} with
* `layout="singleStack"` and {@link Selection} size `l`. * `layout="singleStack"` and {@link Selection} size `l`.
* *
* Card click opens the Figma create modal (node `20246-15829`) with three * Card click opens Overlay / Modal / Card as a routing view of its parts
* editable sections rendered by {@link CommunicationMethodEditFields}. The * ({@link MethodCardPolicyDialog}). The primary action is **Add to Rule** for
* primary action is **Add Platform** for an unselected card and **Save** for a * an unselected card and **Save** for a selected card. **Deselect** is the
* selected card. **Remove** is a danger footer button on selected cards (and * footer on selected cards (**Remove** stays in the kebab) via
* remains in the kebab) via {@link removeMethodCardFromFacetSelection}. * {@link removeMethodCardFromFacetSelection}.
*/ */
import { useState, useCallback, useMemo, useRef } from "react"; import { useState, useCallback, useMemo, useRef } from "react";
@@ -24,9 +24,9 @@ import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton"; 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 CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { communicationPresetFor, emptyCommunicationDetails } from "../../../../../lib/create/finalReviewChipPresets"; import { communicationPresetFor, emptyCommunicationDetails } from "../../../../../lib/create/finalReviewChipPresets";
@@ -105,7 +105,8 @@ export function CommunicationMethodsScreen() {
[comm.methods, selectedIds, state.customMethodCardMetaById], [comm.methods, selectedIds, state.customMethodCardMetaById],
); );
const { sampleCards, compactCardIds, methodById, recommendationsReady } = const rec = m.create.customRule.recommendations;
const { sampleCards, compactCardIds, methodById, recommendationsReady, showDeckLine, compactLimit } =
useMethodCardDeckOrdering( useMethodCardDeckOrdering(
"communication", "communication",
mergedMethods, mergedMethods,
@@ -188,10 +189,14 @@ export function CommunicationMethodsScreen() {
}, },
[markCreateFlowInteraction], [markCreateFlowInteraction],
); );
const communicationSteps = useCommunicationPolicySteps(
pendingDraft ?? emptyCommunicationDetails(),
handleDraftChange,
pendingCardId ? communicationPresetFor(pendingCardId) : undefined,
);
const isSelectedCardModal = const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId); pendingCardId !== null && selectedIds.includes(pendingCardId);
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => { const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false; if (!pendingCardId || !pendingDraft) return false;
@@ -710,34 +715,36 @@ export function CommunicationMethodsScreen() {
markCreateFlowInteraction(); markCreateFlowInteraction();
setExpanded((prev) => !prev); setExpanded((prev) => !prev);
}} }}
hasMore={true} hasMore={sampleCards.length > compactLimit}
toggleLabel={comm.page.seeAllLink} toggleLabel={comm.page.seeAllLink}
compactRecommendedLimit={5} compactRecommendedLimit={compactLimit}
compactCardIds={compactCardIds} compactCardIds={compactCardIds}
layout="singleStack" 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" className="w-full lg:h-full lg:min-h-0 lg:flex-1"
/> />
)} )}
</div> </div>
</CreateFlowTwoColumnSelectShell> </CreateFlowTwoColumnSelectShell>
<Create <MethodCardPolicyDialog
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} cardLabel={modalConfig.title}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} editing={isSelectedCardModal}
showBackButton={false} steps={communicationSteps}
showNextButton={showMethodModalPrimary} onClose={handleCreateModalClose}
showRemoveButton={isSelectedCardModal} onConfirm={handleCreateModalPrimary}
onRemove={handleRemoveSelectedFromModal} onDeselect={
backdropVariant="blurredYellow" isSelectedCardModal ? handleRemoveSelectedFromModal : undefined
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={kebabMenuItems} kebabMenuItems={kebabMenuItems}
> >
{pendingCardId && pendingDraft ? ( {pendingCardId && modalUsesWizardFieldBlocksBody ? (
modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody <CustomMethodCardModalBody
cardId={pendingCardId} cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById} blocksById={state.customMethodCardFieldBlocksById}
@@ -754,15 +761,8 @@ export function CommunicationMethodsScreen() {
: (next) => setDraftFieldBlocks(next) : (next) => setDraftFieldBlocks(next)
} }
/> />
) : (
<CommunicationMethodEditFields
value={pendingDraft}
onChange={handleDraftChange}
generateDraft={communicationPresetFor(pendingCardId)}
/>
)
) : null} ) : null}
</Create> </MethodCardPolicyDialog>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
@@ -7,12 +7,9 @@
* Page chrome is the shared question-and-options shell * Page chrome is the shared question-and-options shell
* (`CreateFlowTwoColumnSelectShell`); options are CardStack `singleStack`. * (`CreateFlowTwoColumnSelectShell`); options are CardStack `singleStack`.
* *
* Card click opens the Figma "Add Approach" create modal (node `20874-172292`) * Card click opens Overlay / Modal / Card as a routing view of its parts
* with four controls rendered by {@link ConflictManagementEditFields}: Core * ({@link MethodCardPolicyDialog}). Confirm persists the chip selection and
* Principle, Applicable Scope (text area), Process Protocol, and Restoration * any edits as a `conflictManagementDetailsById[id]` override.
* & 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.
*/ */
import { useState, useCallback, useMemo, useRef } from "react"; import { useState, useCallback, useMemo, useRef } from "react";
@@ -24,9 +21,9 @@ import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton"; 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 CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { conflictManagementPresetFor, emptyConflictManagementDetails } from "../../../../../lib/create/finalReviewChipPresets"; import { conflictManagementPresetFor, emptyConflictManagementDetails } from "../../../../../lib/create/finalReviewChipPresets";
@@ -105,7 +102,8 @@ export function ConflictManagementScreen() {
[cm.methods, selectedIds, state.customMethodCardMetaById], [cm.methods, selectedIds, state.customMethodCardMetaById],
); );
const { sampleCards, compactCardIds, methodById, recommendationsReady } = const rec = m.create.customRule.recommendations;
const { sampleCards, compactCardIds, methodById, recommendationsReady, showDeckLine, compactLimit } =
useMethodCardDeckOrdering( useMethodCardDeckOrdering(
"conflictManagement", "conflictManagement",
mergedMethods, mergedMethods,
@@ -192,10 +190,14 @@ export function ConflictManagementScreen() {
}, },
[markCreateFlowInteraction], [markCreateFlowInteraction],
); );
const conflictSteps = useConflictPolicySteps(
pendingDraft ?? emptyConflictManagementDetails(),
handleDraftChange,
pendingCardId ? conflictManagementPresetFor(pendingCardId) : undefined,
);
const isSelectedCardModal = const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId); pendingCardId !== null && selectedIds.includes(pendingCardId);
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => { const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false; if (!pendingCardId || !pendingDraft) return false;
@@ -714,34 +716,36 @@ export function ConflictManagementScreen() {
markCreateFlowInteraction(); markCreateFlowInteraction();
setExpanded((prev) => !prev); setExpanded((prev) => !prev);
}} }}
hasMore={true} hasMore={sampleCards.length > compactLimit}
toggleLabel={cm.page.seeAllLink} toggleLabel={cm.page.seeAllLink}
compactRecommendedLimit={5} compactRecommendedLimit={compactLimit}
compactCardIds={compactCardIds} compactCardIds={compactCardIds}
layout="singleStack" 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" className="w-full lg:h-full lg:min-h-0 lg:flex-1"
/> />
)} )}
</div> </div>
</CreateFlowTwoColumnSelectShell> </CreateFlowTwoColumnSelectShell>
<Create <MethodCardPolicyDialog
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} cardLabel={modalConfig.title}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} editing={isSelectedCardModal}
showBackButton={false} steps={conflictSteps}
showNextButton={showMethodModalPrimary} onClose={handleCreateModalClose}
showRemoveButton={isSelectedCardModal} onConfirm={handleCreateModalPrimary}
onRemove={handleRemoveSelectedFromModal} onDeselect={
backdropVariant="blurredYellow" isSelectedCardModal ? handleRemoveSelectedFromModal : undefined
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={kebabMenuItems} kebabMenuItems={kebabMenuItems}
> >
{pendingCardId && pendingDraft ? ( {pendingCardId && modalUsesWizardFieldBlocksBody ? (
modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody <CustomMethodCardModalBody
cardId={pendingCardId} cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById} blocksById={state.customMethodCardFieldBlocksById}
@@ -758,15 +762,8 @@ export function ConflictManagementScreen() {
: (next) => setDraftFieldBlocks(next) : (next) => setDraftFieldBlocks(next)
} }
/> />
) : (
<ConflictManagementEditFields
value={pendingDraft}
onChange={handleDraftChange}
generateDraft={conflictManagementPresetFor(pendingCardId)}
/>
)
) : null} ) : null}
</Create> </MethodCardPolicyDialog>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
@@ -7,13 +7,9 @@
* Page chrome is the shared question-and-options shell * Page chrome is the shared question-and-options shell
* (`CreateFlowTwoColumnSelectShell`); options are CardStack `singleStack`. * (`CreateFlowTwoColumnSelectShell`); options are CardStack `singleStack`.
* *
* Card click opens the Figma create modal (node `20858-13948`) with three * Card click opens Overlay / Modal / Card as a routing view of its parts
* editable sections rendered by {@link MembershipMethodEditFields}. The same * ({@link MethodCardPolicyDialog}). Confirm persists the chip selection and
* field set is reused on `/create/final-review` — see `FinalReviewChipEditModal`. * any edits as a `membershipMethodDetailsById[id]` override.
* 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.
*/ */
import { useState, useCallback, useMemo, useRef } from "react"; import { useState, useCallback, useMemo, useRef } from "react";
@@ -25,9 +21,9 @@ import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton"; 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 CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { membershipPresetFor, emptyMembershipDetails } from "../../../../../lib/create/finalReviewChipPresets"; import { membershipPresetFor, emptyMembershipDetails } from "../../../../../lib/create/finalReviewChipPresets";
@@ -106,7 +102,8 @@ export function MembershipMethodsScreen() {
[mem.methods, selectedIds, state.customMethodCardMetaById], [mem.methods, selectedIds, state.customMethodCardMetaById],
); );
const { sampleCards, compactCardIds, methodById, recommendationsReady } = const rec = m.create.customRule.recommendations;
const { sampleCards, compactCardIds, methodById, recommendationsReady, showDeckLine, compactLimit } =
useMethodCardDeckOrdering( useMethodCardDeckOrdering(
"membership", "membership",
mergedMethods, mergedMethods,
@@ -189,10 +186,14 @@ export function MembershipMethodsScreen() {
}, },
[markCreateFlowInteraction], [markCreateFlowInteraction],
); );
const membershipSteps = useMembershipPolicySteps(
pendingDraft ?? emptyMembershipDetails(),
handleDraftChange,
pendingCardId ? membershipPresetFor(pendingCardId) : undefined,
);
const isSelectedCardModal = const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId); pendingCardId !== null && selectedIds.includes(pendingCardId);
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => { const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false; if (!pendingCardId || !pendingDraft) return false;
@@ -707,34 +708,36 @@ export function MembershipMethodsScreen() {
markCreateFlowInteraction(); markCreateFlowInteraction();
setExpanded((prev) => !prev); setExpanded((prev) => !prev);
}} }}
hasMore={true} hasMore={sampleCards.length > compactLimit}
toggleLabel={mem.page.seeAllLink} toggleLabel={mem.page.seeAllLink}
compactRecommendedLimit={5} compactRecommendedLimit={compactLimit}
compactCardIds={compactCardIds} compactCardIds={compactCardIds}
layout="singleStack" 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" className="w-full lg:h-full lg:min-h-0 lg:flex-1"
/> />
)} )}
</div> </div>
</CreateFlowTwoColumnSelectShell> </CreateFlowTwoColumnSelectShell>
<Create <MethodCardPolicyDialog
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} cardLabel={modalConfig.title}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} editing={isSelectedCardModal}
showBackButton={false} steps={membershipSteps}
showNextButton={showMethodModalPrimary} onClose={handleCreateModalClose}
showRemoveButton={isSelectedCardModal} onConfirm={handleCreateModalPrimary}
onRemove={handleRemoveSelectedFromModal} onDeselect={
backdropVariant="blurredYellow" isSelectedCardModal ? handleRemoveSelectedFromModal : undefined
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={kebabMenuItems} kebabMenuItems={kebabMenuItems}
> >
{pendingCardId && pendingDraft ? ( {pendingCardId && modalUsesWizardFieldBlocksBody ? (
modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody <CustomMethodCardModalBody
cardId={pendingCardId} cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById} blocksById={state.customMethodCardFieldBlocksById}
@@ -751,15 +754,8 @@ export function MembershipMethodsScreen() {
: (next) => setDraftFieldBlocks(next) : (next) => setDraftFieldBlocks(next)
} }
/> />
) : (
<MembershipMethodEditFields
value={pendingDraft}
onChange={handleDraftChange}
generateDraft={membershipPresetFor(pendingCardId)}
/>
)
) : null} ) : null}
</Create> </MethodCardPolicyDialog>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
onClose={handleCloseAddWizard} onClose={handleCloseAddWizard}
@@ -7,20 +7,17 @@
* Layout matches {@link CreateFlowTwoColumnSelectShell}: one column below `lg` (1024px), two columns * 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. * 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 * Card click opens Overlay / Modal / Card as a routing view of its parts
* rendered by {@link DecisionApproachEditFields}: Core Principle, Applicable Scope, Step-by-Step * ({@link MethodCardPolicyDialog}). Confirm persists the chip selection and
* Instructions, Consensus Level, and Objections & Deadlocks. The same field set is reused on * any edits as a `decisionApproachDetailsById[id]` override.
* `/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.
*/ */
import { useState, useCallback, useMemo, useRef } from "react"; import { useState, useCallback, useMemo, useRef } from "react";
import CardStack from "../../../../components/cards/CardStack"; import CardStack from "../../../../components/cards/CardStack";
import HeaderLockup from "../../../../components/type/HeaderLockup"; import HeaderLockup from "../../../../components/type/HeaderLockup";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { MethodCardPolicyDialog } from "../../components/MethodCardPolicyDialog";
import { useDecisionApproachPolicySteps } from "../../components/methodCardPolicySteps";
import InfoMessageBox from "../../../../components/controls/InfoMessageBox"; import InfoMessageBox from "../../../../components/controls/InfoMessageBox";
import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMessageBox/InfoMessageBox.types"; import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMessageBox/InfoMessageBox.types";
import { useMessages } from "../../../../contexts/MessagesContext"; import { useMessages } from "../../../../contexts/MessagesContext";
@@ -30,7 +27,6 @@ import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard";
import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm";
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import { DecisionApproachEditFields } from "../../components/methodEditFields";
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
import { decisionApproachPresetFor, emptyDecisionApproachTextFields } from "../../../../../lib/create/finalReviewChipPresets"; import { decisionApproachPresetFor, emptyDecisionApproachTextFields } from "../../../../../lib/create/finalReviewChipPresets";
@@ -130,7 +126,8 @@ export function DecisionApproachesScreen() {
[da.methods, selectedIds, state.customMethodCardMetaById], [da.methods, selectedIds, state.customMethodCardMetaById],
); );
const { sampleCards, compactCardIds, methodById, recommendationsReady } = const rec = m.create.customRule.recommendations;
const { sampleCards, compactCardIds, methodById, recommendationsReady, showDeckLine, compactLimit } =
useMethodCardDeckOrdering( useMethodCardDeckOrdering(
"decisionApproaches", "decisionApproaches",
mergedMethods, mergedMethods,
@@ -237,10 +234,17 @@ export function DecisionApproachesScreen() {
}, },
[markCreateFlowInteraction], [markCreateFlowInteraction],
); );
const decisionSteps = useDecisionApproachPolicySteps(
pendingDraft ??
emptyDecisionApproachTextFields(
decisionApproachPresetFor(pendingCardId ?? ""),
),
handleDraftChange,
pendingCardId ? decisionApproachPresetFor(pendingCardId) : undefined,
);
const isSelectedCardModal = const isSelectedCardModal =
pendingCardId !== null && selectedIds.includes(pendingCardId); pendingCardId !== null && selectedIds.includes(pendingCardId);
const showMethodModalPrimary = true;
const customFacetDetailsMatchPreset = useMemo(() => { const customFacetDetailsMatchPreset = useMemo(() => {
if (!pendingCardId || !pendingDraft) return false; if (!pendingCardId || !pendingDraft) return false;
@@ -776,7 +780,7 @@ export function DecisionApproachesScreen() {
onCardSelect={handleCardSelect} onCardSelect={handleCardSelect}
expanded={expanded} expanded={expanded}
onToggleExpand={handleToggleExpand} onToggleExpand={handleToggleExpand}
hasMore={true} hasMore={sampleCards.length > compactLimit}
toggleLabel={da.cardStack.toggleSeeAll} toggleLabel={da.cardStack.toggleSeeAll}
showLessLabel={da.cardStack.toggleShowLess} showLessLabel={da.cardStack.toggleShowLess}
title="" title=""
@@ -794,32 +798,34 @@ export function DecisionApproachesScreen() {
) )
} }
layout="singleStack" layout="singleStack"
compactRecommendedLimit={5} compactRecommendedLimit={compactLimit}
compactCardIds={compactCardIds} 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" className="w-full lg:h-full lg:min-h-0 lg:flex-1"
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
)} )}
</div> </div>
<Create <MethodCardPolicyDialog
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={handleCreateModalClose} cardLabel={modalConfig.title}
onNext={handleCreateModalPrimary}
title={modalConfig.title}
description={modalConfig.description} description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText} editing={isSelectedCardModal}
showBackButton={false} steps={decisionSteps}
showNextButton={showMethodModalPrimary} onClose={handleCreateModalClose}
showRemoveButton={isSelectedCardModal} onConfirm={handleCreateModalPrimary}
onRemove={handleRemoveSelectedFromModal} onDeselect={
backdropVariant="blurredYellow" isSelectedCardModal ? handleRemoveSelectedFromModal : undefined
}
kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel} kebabTriggerAriaLabel={modalKebabMenu.triggerAriaLabel}
kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel} kebabMenuAriaLabel={modalKebabMenu.menuAriaLabel}
kebabMenuItems={kebabMenuItems} kebabMenuItems={kebabMenuItems}
> >
{pendingCardId && pendingDraft ? ( {pendingCardId && modalUsesWizardFieldBlocksBody ? (
modalUsesWizardFieldBlocksBody ? (
<CustomMethodCardModalBody <CustomMethodCardModalBody
cardId={pendingCardId} cardId={pendingCardId}
blocksById={state.customMethodCardFieldBlocksById} blocksById={state.customMethodCardFieldBlocksById}
@@ -836,15 +842,8 @@ export function DecisionApproachesScreen() {
: (next) => setDraftFieldBlocks(next) : (next) => setDraftFieldBlocks(next)
} }
/> />
) : (
<DecisionApproachEditFields
value={pendingDraft}
onChange={handleDraftChange}
generateDraft={decisionApproachPresetFor(pendingCardId)}
/>
)
) : null} ) : null}
</Create> </MethodCardPolicyDialog>
</CreateFlowTwoColumnSelectShell> </CreateFlowTwoColumnSelectShell>
<CustomMethodCardWizard <CustomMethodCardWizard
isOpen={addCustomWizardOpen} isOpen={addCustomWizardOpen}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { memo, useCallback, useState } from "react"; import { memo, useCallback, useEffect, useState } from "react";
import { useTranslation } from "../../../contexts/MessagesContext"; import { useTranslation } from "../../../contexts/MessagesContext";
import { CardStackView } from "./CardStack.view"; import { CardStackView } from "./CardStack.view";
import type { CardStackProps } from "./CardStack.types"; import type { CardStackProps } from "./CardStack.types";
@@ -29,7 +29,7 @@ const CardStackContainer = memo<CardStackProps>(
title = "", title = "",
description = "", description = "",
layout = "default", layout = "default",
compactRecommendedLimit = 5, compactRecommendedLimit = 7,
compactCardIds, compactCardIds,
compactDesktopLayout: compactDesktopLayoutProp = "grid", compactDesktopLayout: compactDesktopLayoutProp = "grid",
headerLockupSize, headerLockupSize,
@@ -39,16 +39,25 @@ const CardStackContainer = memo<CardStackProps>(
addCardLabel = "", addCardLabel = "",
addCardAriaLabel = "", addCardAriaLabel = "",
onAddCard, onAddCard,
deckLine = "",
searchPlaceholder = "",
alternativePrefix = "",
onAlternativeSelect,
}) => { }) => {
const t = useTranslation("controlsChrome"); const t = useTranslation("controlsChrome");
const [internalExpanded, setInternalExpanded] = useState(false); const [internalExpanded, setInternalExpanded] = useState(false);
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>( const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(
[], [],
); );
const [searchQuery, setSearchQuery] = useState("");
const expanded = const expanded =
controlledExpanded !== undefined ? controlledExpanded : internalExpanded; controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
useEffect(() => {
if (!expanded) setSearchQuery("");
}, [expanded]);
const handleToggleExpand = useCallback(() => { const handleToggleExpand = useCallback(() => {
if (controlledOnToggleExpand) { if (controlledOnToggleExpand) {
controlledOnToggleExpand(); controlledOnToggleExpand();
@@ -102,6 +111,12 @@ const CardStackContainer = memo<CardStackProps>(
addCardLabel={addCardLabel} addCardLabel={addCardLabel}
addCardAriaLabel={addCardAriaLabel} addCardAriaLabel={addCardAriaLabel}
onAddCard={onAddCard} onAddCard={onAddCard}
deckLine={deckLine}
searchPlaceholder={searchPlaceholder}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
alternativePrefix={alternativePrefix}
onAlternativeSelect={onAlternativeSelect}
/> />
); );
}, },
@@ -6,6 +6,10 @@ export interface CardStackItem {
label: string; label: string;
supportText?: string; supportText?: string;
recommended?: boolean; 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 { 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 */ /** "default" = compact grid/column + expanded 2-col 142px tiles; "singleStack" = always one column, expand shows more in same stack */
layout?: "default" | "singleStack"; 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; 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 * Optional explicit list of card ids to render in the compact slot, in
* order. When provided, this overrides the default * order. When provided, this overrides the default
@@ -78,4 +89,10 @@ export interface CardStackViewProps {
addCardLabel: string; addCardLabel: string;
addCardAriaLabel: string; addCardAriaLabel: string;
onAddCard?: () => void; 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 HeaderLockup from "../../type/HeaderLockup";
import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup.types"; import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup.types";
import Selection from "../Selection"; 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`). */ /** Figma Card / CardSelection on Compact + Expanded Card Stack (`281×142`). */
const FIGMA_CARD_SELECTION_TILE_CLASS = 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({ function CardStackAddCardButton({
label, label,
ariaLabel, ariaLabel,
@@ -98,6 +179,12 @@ export function CardStackView({
addCardLabel, addCardLabel,
addCardAriaLabel, addCardAriaLabel,
onAddCard, onAddCard,
deckLine,
searchPlaceholder,
searchQuery,
onSearchQueryChange,
alternativePrefix,
onAlternativeSelect,
}: CardStackViewProps) { }: CardStackViewProps) {
const addTile = const addTile =
showAddCard && onAddCard && addCardLabel.length > 0 ? ( 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 // At lg+ the cards scroll inside this column and the See all / Show less
// control stays pinned below (Figma Flow — Right Rail). // control stays pinned below (Figma Flow — Right Rail).
if (layout === "singleStack") { if (layout === "singleStack") {
const displayedCards = expanded ? cards : compactCards; const displayedCards = (expanded ? cards : compactCards).filter((item) =>
expanded ? matchesSearch(item, searchQuery) : true,
);
return ( return (
<div <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}`} 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} size={lockupSize}
wrapperClassName="min-w-0 shrink-0" 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"> <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) => ( {displayedCards.map((item) => (
<Selection <StackCard
key={item.id} key={item.id}
id={item.id} item={item}
label={item.label} expanded={expanded}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)} selected={isSelected(item.id)}
size="l" size="l"
showInfoIcon={false} onSelect={onCardSelect}
onClick={() => onCardSelect(item.id)} onAlternativeSelect={onAlternativeSelect}
alternativePrefix={alternativePrefix}
/> />
))} ))}
{addTile} {addTile}
+1 -1
View File
@@ -72,7 +72,7 @@ function chipSurfaceStyle(
return paint( return paint(
"transparent", "transparent",
"var(--color-content-default-brand-primary)", "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]); 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 * Offer the signed-off deck chips, then this card's scopes, then the coverage
* them onto `applicableScope` (unchecked keys must not publish as defaults). * checklist labels. Unchecked extras are display-only and must not publish
* as defaults.
*/ */
export function withDecisionApproachKeyResourceScopes( export function withDecisionApproachKeyResourceScopes(
scopes: readonly string[], scopes: readonly string[],
): string[] { ): string[] {
return uniquePreserveOrder([ return uniquePreserveOrder([
...decisionApproachDefaultScopeChips(),
...scopes, ...scopes,
...decisionApproachKeyResourceLabels(), ...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" "noSetShare": "No set share"
}, },
"scopeAddButtonLabel": "Add a scope", "scopeAddButtonLabel": "Add a scope",
"defaultScopeChips": [
"Changing the Rule",
"Money",
"Projects",
"Asking someone to leave"
],
"methods": [ "methods": [
{ {
"id": "decision-speeds", "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.", "_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 group", "deckLine": "Recommended for your community",
"searchPlaceholder": "Search this deck",
"tags": { "tags": {
"recommended": "RECOMMENDED", "recommended": "RECOMMENDED",
"everyRuleHas": "Every Rule has" "everyRuleHas": "Every Rule has"
@@ -47,5 +48,5 @@
"records": "you keep information about the people you serve", "records": "you keep information about the people you serve",
"risky": "your work carries physical risk, or needs a license, permit or inspection" "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 createConflictManagement from "./create/customRule/conflictManagement.json";
import createCustomMethodCardWizard from "./create/customRule/customMethodCardWizard.json"; import createCustomMethodCardWizard from "./create/customRule/customMethodCardWizard.json";
import createModalKebabMenu from "./create/customRule/modalKebabMenu.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 // create – stage 3: reviewAndComplete
import createConfirmStakeholders from "./create/reviewAndComplete/confirmStakeholders.json"; import createConfirmStakeholders from "./create/reviewAndComplete/confirmStakeholders.json";
@@ -133,6 +135,8 @@ export default {
conflictManagement: createConflictManagement, conflictManagement: createConflictManagement,
customMethodCardWizard: createCustomMethodCardWizard, customMethodCardWizard: createCustomMethodCardWizard,
modalKebabMenu: createModalKebabMenu, modalKebabMenu: createModalKebabMenu,
cardDialog: createCardDialog,
recommendations: createRecommendations,
}, },
reviewAndComplete: { reviewAndComplete: {
confirmStakeholders: createConfirmStakeholders, 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.color).toContain("--color-content-default-brand-primary");
expect(chip.style.backgroundColor).toBe("transparent"); expect(chip.style.backgroundColor).toBe("transparent");
expect(chip.style.borderColor).toContain( 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} * snapping back to preset copy. See {@link CommunicationMethodEditFields}
* and `buildPublishPayload` for the read side. * 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", () => { describe("CommunicationMethodsScreen — Add card persistence", () => {
it("starts empty and Generate draft inserts the catalog preset", async () => { it("starts empty and Generate draft inserts the catalog preset", async () => {
let latest: CreateFlowState = {}; let latest: CreateFlowState = {};
@@ -79,24 +100,24 @@ describe("CommunicationMethodsScreen — Add card persistence", () => {
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
expect(within(dialog).queryByAltText("Help")).not.toBeInTheDocument(); expect(within(dialog).queryByAltText("Help")).not.toBeInTheDocument();
const textboxes = within(dialog).getAllByRole("textbox"); openPolicyStep(dialog, "Core principle and scope");
expect(textboxes.length).toBe(3); const corePrincipleField = within(dialog).getByRole(
const corePrincipleField = textboxes[0] as HTMLTextAreaElement; "textbox",
) as HTMLTextAreaElement;
expect(corePrincipleField.value).toBe(""); expect(corePrincipleField.value).toBe("");
const generateButtons = within(dialog).getAllByRole("button", { fireEvent.click(
name: "Generate draft", within(dialog).getByRole("button", { name: "Generate draft" }),
}); );
expect(generateButtons.length).toBe(3);
fireEvent.click(generateButtons[0]);
expect(corePrincipleField.value).toBe( expect(corePrincipleField.value).toBe(
communicationPresetFor("signal").corePrinciple, communicationPresetFor("signal").corePrinciple,
); );
fireEvent.change(corePrincipleField, { target: { value: "Custom principle" } }); fireEvent.change(corePrincipleField, { target: { value: "Custom principle" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
fireEvent.click( fireEvent.click(
within(dialog).getByRole("button", { 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], screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0],
); );
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
const textareas = within(dialog).getAllByRole( expect(within(dialog).getByText("Saved principle")).toBeInTheDocument();
"textbox", expect(within(dialog).getByText("Saved logistics")).toBeInTheDocument();
) as HTMLTextAreaElement[]; expect(within(dialog).getByText("Saved coc")).toBeInTheDocument();
expect(textareas.length).toBe(3); openPolicyStep(dialog, "Core principle and scope");
expect(textareas[0].value).toBe("Saved principle"); expect(within(dialog).getByRole("textbox")).toHaveValue("Saved principle");
expect(textareas[1].value).toBe("Saved logistics");
expect(textareas[2].value).toBe("Saved coc");
}); });
it("persists edits and closes when Save is clicked on an already-selected platform", async () => { 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], screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0],
); );
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
const textareas = within(dialog).getAllByRole( openPolicyStep(dialog, "Core principle and scope");
"textbox", fireEvent.change(within(dialog).getByRole("textbox"), {
) as HTMLTextAreaElement[]; target: { value: "Edited principle" },
fireEvent.change(textareas[0], { target: { value: "Edited principle" } }); });
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => { await waitFor(() => {
@@ -239,14 +259,14 @@ describe("CommunicationMethodsScreen — Add card persistence", () => {
})[0], })[0],
); );
const dialog = await screen.findByRole("dialog"); 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).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 () => { 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], screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0],
); );
const dialog = await screen.findByRole("dialog"); 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" })); fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
expect( expect(
screen.getByRole("menuitem", { name: "Customize" }), screen.getByRole("menuitem", { name: "Customize" }),
).toBeInTheDocument(); ).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 () => { 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], screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0],
); );
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
const textboxes = within(dialog).getAllByRole( openPolicyStep(dialog, "Core principle and scope");
"textbox", fireEvent.change(within(dialog).getByRole("textbox"), {
) as HTMLTextAreaElement[]; target: { value: "Edited principle" },
fireEvent.change(textboxes[0], { target: { value: "Edited principle" } }); });
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "Escape" });
const keepEditing = await screen.findByRole("button", { const keepEditing = await screen.findByRole("button", {
name: "Keep editing", name: "Keep editing",
@@ -390,10 +409,11 @@ describe("CommunicationMethodsScreen — Add card persistence", () => {
screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0], screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0],
); );
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
const textboxes = within(dialog).getAllByRole( openPolicyStep(dialog, "Core principle and scope");
"textbox", fireEvent.change(within(dialog).getByRole("textbox"), {
) as HTMLTextAreaElement[]; target: { value: "Edited principle" },
fireEvent.change(textboxes[0], { target: { value: "Edited principle" } }); });
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "Escape" });
await confirmDiscardCustomizeEdits(); await confirmDiscardCustomizeEdits();
@@ -473,11 +493,7 @@ describe("CommunicationMethodsScreen — Add card persistence", () => {
screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0], screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0],
); );
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
for (const button of within(dialog).getAllByRole("button", { generateAllCommunicationSteps(dialog);
name: "Generate draft",
})) {
fireEvent.click(button);
}
fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); fireEvent.click(within(dialog).getByRole("button", { name: "More options" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); 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], screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0],
); );
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
const textboxes = within(dialog).getAllByRole( openPolicyStep(dialog, "Core principle and scope");
"textbox", fireEvent.change(within(dialog).getByRole("textbox"), {
) as HTMLTextAreaElement[]; target: { value: "Unsaved principle" },
fireEvent.change(textboxes[0], { target: { value: "Unsaved principle" } }); });
expect(dispatchBeforeUnload()).toBe(true); expect(dispatchBeforeUnload()).toBe(true);
}); });
@@ -22,13 +22,8 @@ describe("ConflictManagementScreen", () => {
})[0], })[0],
); );
const dialog = await screen.findByRole("dialog"); 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( expect(
within(dialog).queryByRole("button", { name: "Remove" }), within(dialog).queryByRole("button", { name: "Deselect" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
fireEvent.click( fireEvent.click(
within(dialog).getByRole("button", { name: "More options" }), within(dialog).getByRole("button", { name: "More options" }),
@@ -36,5 +31,13 @@ describe("ConflictManagementScreen", () => {
expect( expect(
screen.getByRole("menuitem", { name: "Customize" }), screen.getByRole("menuitem", { name: "Customize" }),
).toBeInTheDocument(); ).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], })[0],
); );
const dialog = await screen.findByRole("dialog"); 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( expect(
within(dialog).queryByRole("button", { name: "Remove" }), within(dialog).queryByRole("button", { name: "Deselect" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
fireEvent.click( fireEvent.click(
within(dialog).getByRole("button", { name: "More options" }), within(dialog).getByRole("button", { name: "More options" }),
@@ -36,5 +31,13 @@ describe("MembershipMethodsScreen", () => {
expect( expect(
screen.getByRole("menuitem", { name: "Customize" }), screen.getByRole("menuitem", { name: "Customize" }),
).toBeInTheDocument(); ).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"); const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument(); expect(dialog).toBeInTheDocument();
expect(within(dialog).getByText("Signal")).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(); const user = userEvent.setup();
render(<CommunicationMethodsScreen />); render(<CommunicationMethodsScreen />);
@@ -49,15 +49,15 @@ describe("Create flow communication-methods page", () => {
}); });
await user.click(signalCards[0]); await user.click(signalCards[0]);
const dialog = screen.getByRole("dialog"); 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]); await user.click(signalCards[0]);
const dialogAgain = screen.getByRole("dialog"); const dialogAgain = screen.getByRole("dialog");
expect( expect(
within(dialogAgain).getByRole("button", { name: "Remove" }), within(dialogAgain).getByRole("button", { name: "Deselect" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
within(dialogAgain).queryByRole("button", { name: "Add card" }), within(dialogAgain).queryByRole("button", { name: "Add to Rule" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect( expect(
within(dialogAgain).getByRole("button", { name: "Save" }), within(dialogAgain).getByRole("button", { name: "Save" }),
@@ -67,7 +67,7 @@ describe("Create flow communication-methods page", () => {
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument(); 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(); const user = userEvent.setup();
render(<CommunicationMethodsScreen />); render(<CommunicationMethodsScreen />);
@@ -77,7 +77,7 @@ describe("Create flow communication-methods page", () => {
await user.click(signalCards[0]); await user.click(signalCards[0]);
await user.click( await user.click(
within(screen.getByRole("dialog")).getByRole("button", { 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(signalCards[0]);
await user.click( 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"); expect(signalCards[0]).not.toHaveTextContent("SELECTED");
@@ -101,7 +103,7 @@ describe("Create flow communication-methods page", () => {
await user.click(signalCards[0]); await user.click(signalCards[0]);
await user.click( await user.click(
within(screen.getByRole("dialog")).getByRole("button", { 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]); await user.click(signalCards[0]);
const dialog = screen.getByRole("dialog"); 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" })); await user.click(within(dialog).getByRole("button", { name: "More options" }));
expect(screen.getByRole("menuitem", { name: "Customize" })).toBeInTheDocument(); 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 () => { 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], screen.getAllByRole("button", { name: /My policy: Desc/ })[0],
); );
const dialog = await screen.findByRole("dialog"); 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"); const textboxes = within(dialog).getAllByRole("textbox");
expect(textboxes.length).toBeGreaterThan(0); expect(textboxes.length).toBeGreaterThan(0);
for (const field of textboxes) { for (const field of textboxes) {
expect(field).not.toBeDisabled(); 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( expect(
within(dialog).queryByText("No custom fields yet."), screen.getByRole("searchbox", { name: "Search this deck" }),
).not.toBeInTheDocument(); ).toBeInTheDocument();
}); });
test("renders without error", () => { test("renders without error", () => {
@@ -419,7 +444,7 @@ describe("Create flow communication-methods page", () => {
const dupId = dupIds[0]; const dupId = dupIds[0];
expect(latest.selectedCommunicationMethodIds).toEqual([CUSTOM_POLICY_ID]); expect(latest.selectedCommunicationMethodIds).toEqual([CUSTOM_POLICY_ID]);
expect( expect(
within(dialog).getByRole("button", { name: "Add card" }), within(dialog).getByRole("button", { name: "Add to Rule" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(metaAfterDup[dupId].label.endsWith(" (copy)")).toBe(true); expect(metaAfterDup[dupId].label.endsWith(" (copy)")).toBe(true);
expect(within(dialog).getByRole("heading", { level: 1 })).toHaveTextContent( 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(screen.getByRole("button", { name: /^Consent \(sociocracy\):/ }));
await user.click( await user.click(
within(await screen.findByRole("dialog")).getByRole("button", { within(await screen.findByRole("dialog")).getByRole("button", {
name: "Add card", name: "Add to Rule",
}), }),
); );
await waitFor(() => { await waitFor(() => {
@@ -211,17 +211,20 @@ describe("Create flow decision-approaches page", () => {
}), }),
); );
const dialog = await screen.findByRole("dialog"); 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( await user.click(
within(dialog).getByRole("button", { name: "More options" }), within(dialog).getByRole("button", { name: "More options" }),
); );
expect( expect(
screen.getByRole("menuitem", { name: "Customize" }), screen.getByRole("menuitem", { name: "Customize" }),
).toBeInTheDocument(); ).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 () => { 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(); expect(dialog).toBeInTheDocument();
const confirmButton = within(dialog).getByRole("button", { const confirmButton = within(dialog).getByRole("button", {
name: "Add card", name: "Add to Rule",
}); });
await user.click(confirmButton); await user.click(confirmButton);
expect(screen.getByText("SELECTED")).toBeInTheDocument(); 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(); const user = userEvent.setup();
render(<DecisionApproachesScreen />); render(<DecisionApproachesScreen />);
@@ -255,17 +258,17 @@ describe("Create flow decision-approaches page", () => {
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
await user.click( await user.click(
within(dialog).getByRole("button", { within(dialog).getByRole("button", {
name: "Add card", name: "Add to Rule",
}), }),
); );
await user.click(card); await user.click(card);
const dialogAgain = screen.getByRole("dialog"); const dialogAgain = screen.getByRole("dialog");
expect( expect(
within(dialogAgain).getByRole("button", { name: "Remove" }), within(dialogAgain).getByRole("button", { name: "Deselect" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
within(dialogAgain).queryByRole("button", { name: "Add card" }), within(dialogAgain).queryByRole("button", { name: "Add to Rule" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect( expect(
within(dialogAgain).getByRole("button", { name: "Save" }), within(dialogAgain).getByRole("button", { name: "Save" }),
@@ -275,7 +278,7 @@ describe("Create flow decision-approaches page", () => {
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument(); 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(); const user = userEvent.setup();
render(<DecisionApproachesScreen />); render(<DecisionApproachesScreen />);
@@ -285,7 +288,7 @@ describe("Create flow decision-approaches page", () => {
await user.click(card); await user.click(card);
await user.click( await user.click(
within(await screen.findByRole("dialog")).getByRole("button", { 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(card);
await user.click( await user.click(
within(screen.getByRole("dialog")).getByRole("button", { name: "Remove" }), within(screen.getByRole("dialog")).getByRole("button", {
name: "Deselect",
}),
); );
expect(card).not.toHaveTextContent("SELECTED"); expect(card).not.toHaveTextContent("SELECTED");
@@ -309,17 +314,21 @@ describe("Create flow decision-approaches page", () => {
await user.click(card); await user.click(card);
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
await user.click( await user.click(
within(dialog).getByRole("button", { name: "Add card" }), within(dialog).getByRole("button", { name: "Add to Rule" }),
); );
await user.click(card); await user.click(card);
const dialogAgain = await screen.findByRole("dialog"); const dialogAgain = await screen.findByRole("dialog");
await user.click(
within(dialogAgain).getByRole("button", { name: "Core principle" }),
);
const principleField = within(dialogAgain).getByRole("textbox", { const principleField = within(dialogAgain).getByRole("textbox", {
name: /core principle/i, name: /core principle/i,
}); });
await user.clear(principleField); await user.clear(principleField);
await user.type(principleField, "Edited principle"); 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 user.click(within(dialogAgain).getByRole("button", { name: "Save" }));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@@ -336,7 +345,7 @@ describe("Create flow decision-approaches page", () => {
await user.click(card); await user.click(card);
await user.click( await user.click(
within(await screen.findByRole("dialog")).getByRole("button", { 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"); 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( expect(
within(dialog).getByRole("button", { within(dialog).getByRole("button", {
name: "Select Changing this Rule", name: "Select Changing this Rule",
@@ -431,6 +448,9 @@ describe("Create flow decision-approaches page", () => {
}), }),
); );
const lazyDialog = await screen.findByRole("dialog"); const lazyDialog = await screen.findByRole("dialog");
await user.click(
within(lazyDialog).getByRole("button", { name: "Applicable scope" }),
);
expect( expect(
within(lazyDialog).getByRole("button", { within(lazyDialog).getByRole("button", {
name: "Select Shared money", name: "Select Shared money",
@@ -448,6 +468,9 @@ describe("Create flow decision-approaches page", () => {
}), }),
); );
const lazyDialog = await screen.findByRole("dialog"); const lazyDialog = await screen.findByRole("dialog");
await user.click(
within(lazyDialog).getByRole("button", { name: "Applicable scope" }),
);
await user.click( await user.click(
within(lazyDialog).getByRole("button", { within(lazyDialog).getByRole("button", {
name: "Select Shared money", name: "Select Shared money",
@@ -457,7 +480,10 @@ describe("Create flow decision-approaches page", () => {
screen.getByRole("checkbox", { name: "Shared money" }), screen.getByRole("checkbox", { name: "Shared money" }),
).toBeChecked(); ).toBeChecked();
await user.click( 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(() => { await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@@ -469,6 +495,9 @@ describe("Create flow decision-approaches page", () => {
}), }),
); );
const doocracyDialog = await screen.findByRole("dialog"); const doocracyDialog = await screen.findByRole("dialog");
await user.click(
within(doocracyDialog).getByRole("button", { name: "Applicable scope" }),
);
expect( expect(
within(doocracyDialog).getByRole("button", { within(doocracyDialog).getByRole("button", {
name: "Select Shared money", name: "Select Shared money",
@@ -482,6 +511,9 @@ describe("Create flow decision-approaches page", () => {
expect( expect(
screen.getByRole("checkbox", { name: "Shared money" }), screen.getByRole("checkbox", { name: "Shared money" }),
).not.toBeChecked(); ).not.toBeChecked();
await user.click(
within(doocracyDialog).getByRole("button", { name: "Save" }),
);
await user.click( await user.click(
within(doocracyDialog).getByRole("button", { name: "Close" }), within(doocracyDialog).getByRole("button", { name: "Close" }),
); );
@@ -499,6 +531,9 @@ describe("Create flow decision-approaches page", () => {
}), }),
); );
const lazyAgain = await screen.findByRole("dialog"); const lazyAgain = await screen.findByRole("dialog");
await user.click(
within(lazyAgain).getByRole("button", { name: "Applicable scope" }),
);
expect( expect(
within(lazyAgain).getByRole("button", { within(lazyAgain).getByRole("button", {
name: "Deselect Shared money", name: "Deselect Shared money",
@@ -520,7 +555,7 @@ describe("Create flow decision-approaches page", () => {
); );
const lazyDialog = await screen.findByRole("dialog"); const lazyDialog = await screen.findByRole("dialog");
await user.click( await user.click(
within(lazyDialog).getByRole("button", { name: "Add card" }), within(lazyDialog).getByRole("button", { name: "Add to Rule" }),
); );
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@@ -532,6 +567,9 @@ describe("Create flow decision-approaches page", () => {
}), }),
); );
const doocracyDialog = await screen.findByRole("dialog"); const doocracyDialog = await screen.findByRole("dialog");
await user.click(
within(doocracyDialog).getByRole("button", { name: "Applicable scope" }),
);
await user.click( await user.click(
screen.getByRole("checkbox", { name: "Shared money" }), screen.getByRole("checkbox", { name: "Shared money" }),
); );
@@ -540,6 +578,9 @@ describe("Create flow decision-approaches page", () => {
name: "Deselect Shared money", name: "Deselect Shared money",
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
await user.click(
within(doocracyDialog).getByRole("button", { name: "Save" }),
);
await user.click( await user.click(
within(doocracyDialog).getByRole("button", { name: "Close" }), within(doocracyDialog).getByRole("button", { name: "Close" }),
); );
@@ -554,6 +595,9 @@ describe("Create flow decision-approaches page", () => {
}), }),
); );
const lazyAgain = await screen.findByRole("dialog"); const lazyAgain = await screen.findByRole("dialog");
await user.click(
within(lazyAgain).getByRole("button", { name: "Applicable scope" }),
);
expect( expect(
within(lazyAgain).getByRole("button", { within(lazyAgain).getByRole("button", {
name: "Select Shared money", name: "Select Shared money",
@@ -571,6 +615,9 @@ describe("Create flow decision-approaches page", () => {
}), }),
); );
const lazyDialog = await screen.findByRole("dialog"); const lazyDialog = await screen.findByRole("dialog");
await user.click(
within(lazyDialog).getByRole("button", { name: "Applicable scope" }),
);
await user.click( await user.click(
within(lazyDialog).getByRole("button", { within(lazyDialog).getByRole("button", {
name: "Select Shared money", name: "Select Shared money",
@@ -580,7 +627,10 @@ describe("Create flow decision-approaches page", () => {
screen.getByRole("checkbox", { name: "Shared money" }), screen.getByRole("checkbox", { name: "Shared money" }),
).toBeChecked(); ).toBeChecked();
await user.click( 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(() => { await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@@ -592,6 +642,9 @@ describe("Create flow decision-approaches page", () => {
}), }),
); );
const doocracyDialog = await screen.findByRole("dialog"); const doocracyDialog = await screen.findByRole("dialog");
await user.click(
within(doocracyDialog).getByRole("button", { name: "Applicable scope" }),
);
await user.click( await user.click(
within(doocracyDialog).getByRole("button", { within(doocracyDialog).getByRole("button", {
name: "Select Big decisions", name: "Select Big decisions",
@@ -609,7 +662,10 @@ describe("Create flow decision-approaches page", () => {
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
await user.click( 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(() => { await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@@ -628,6 +684,9 @@ describe("Create flow decision-approaches page", () => {
}), }),
); );
const lazyAgain = await screen.findByRole("dialog"); const lazyAgain = await screen.findByRole("dialog");
await user.click(
within(lazyAgain).getByRole("button", { name: "Applicable scope" }),
);
expect( expect(
within(lazyAgain).getByRole("button", { within(lazyAgain).getByRole("button", {
name: "Deselect Shared money", name: "Deselect Shared money",
@@ -638,6 +697,9 @@ describe("Create flow decision-approaches page", () => {
name: "Select Big decisions", name: "Select Big decisions",
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
await user.click(
within(lazyAgain).getByRole("button", { name: "Save" }),
);
await user.click( await user.click(
within(lazyAgain).getByRole("button", { name: "Close" }), within(lazyAgain).getByRole("button", { name: "Close" }),
); );
@@ -651,6 +713,9 @@ describe("Create flow decision-approaches page", () => {
}), }),
); );
const doocracyAgain = await screen.findByRole("dialog"); const doocracyAgain = await screen.findByRole("dialog");
await user.click(
within(doocracyAgain).getByRole("button", { name: "Applicable scope" }),
);
expect( expect(
within(doocracyAgain).getByRole("button", { within(doocracyAgain).getByRole("button", {
name: "Deselect Big decisions", name: "Deselect Big decisions",
+109
View File
@@ -155,6 +155,115 @@ describe("CardStack Component", () => {
expect(screen.getAllByText("SELECTED").length).toBeGreaterThanOrEqual(1); 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", () => { test("calls onCardSelect when re-clicking an already selected card", () => {
const onCardSelect = vi.fn(); const onCardSelect = vi.fn();
render( render(
@@ -9,6 +9,7 @@ import {
selectedKeyResourceLabelsFromCheckedIds, selectedKeyResourceLabelsFromCheckedIds,
stringArraysEqual, stringArraysEqual,
syncDecisionApproachKeyResourceDetails, syncDecisionApproachKeyResourceDetails,
decisionApproachDefaultScopeChips,
withDecisionApproachKeyResourceScopes, withDecisionApproachKeyResourceScopes,
} from "../../lib/create/decisionApproachKeyResources"; } from "../../lib/create/decisionApproachKeyResources";
import type { DecisionApproachDetailEntry } from "../../app/(app)/create/types"; import type { DecisionApproachDetailEntry } from "../../app/(app)/create/types";
@@ -158,10 +159,20 @@ describe("decisionApproachKeyResources", () => {
expect(decisionApproachKeyResourceLabels()).toHaveLength(7); 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( expect(
withDecisionApproachKeyResourceScopes(["Daily Operations"]), withDecisionApproachKeyResourceScopes(["Daily Operations"]),
).toEqual([ ).toEqual([
"Changing the Rule",
"Money",
"Projects",
"Asking someone to leave",
"Daily Operations", "Daily Operations",
"Everyday decisions", "Everyday decisions",
"Big 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");
});
});