Show seven cards on the four method decks and open each as a routing overlay of its parts, so groups write from empty fields with Generate draft, search the full deck, and see unselected scope chips with enough contrast.

This commit is contained in:
adilallo committed 2026-11-05 22:43:30 -07:00
1 parent dea7dd7fbd
commit 46656f52d3
28 files changed
+1349 -318

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) => {
id: entry.id, const recommendation = deck.recommendedById.get(entry.id);
label: entry.label, const whyLine =
supportText: entry.supportText, recommendation && recommendation.tag !== "everyRuleHas"
recommended: recommendedIds.has(entry.id), ? formatRecommendationWhyLine(recommendation.reason, answers, copy)
})), : "";
[rankedMethods, recommendedIds], const alternativeId = recommendation?.alternative;
const alternativeMethod = alternativeId
? rankedMethods.find((method) => method.id === alternativeId) ??
methods.find((method) => method.id === alternativeId)
: undefined;
return {
id: entry.id,
label: entry.label,
supportText: entry.supportText,
compactSupportText:
whyLine && compactIdSet.has(entry.id) ? whyLine : undefined,
recommended: recommendedIds.has(entry.id),
alternative:
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;
@@ -712,57 +717,52 @@ export function CommunicationMethodsScreen() {
}} }}
hasMore={true} hasMore={true}
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} blocksOverride={
blocksOverride={ draftFieldBlocks !== null ? draftFieldBlocks : undefined
draftFieldBlocks !== null ? draftFieldBlocks : undefined }
} policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]} showPolicyContentLockupWhenNoBlocks={
showPolicyContentLockupWhenNoBlocks={ draftFieldBlocks === null || draftFieldBlocks.length === 0
draftFieldBlocks === null || draftFieldBlocks.length === 0 }
} onFieldBlocksChange={
onFieldBlocksChange={ draftFieldBlocks === null
draftFieldBlocks === null ? undefined
? undefined : (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;
@@ -716,57 +718,52 @@ export function ConflictManagementScreen() {
}} }}
hasMore={true} hasMore={true}
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} blocksOverride={
blocksOverride={ draftFieldBlocks !== null ? draftFieldBlocks : undefined
draftFieldBlocks !== null ? draftFieldBlocks : undefined }
} policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]} showPolicyContentLockupWhenNoBlocks={
showPolicyContentLockupWhenNoBlocks={ draftFieldBlocks === null || draftFieldBlocks.length === 0
draftFieldBlocks === null || draftFieldBlocks.length === 0 }
} onFieldBlocksChange={
onFieldBlocksChange={ draftFieldBlocks === null
draftFieldBlocks === null ? undefined
? undefined : (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;
@@ -709,57 +710,52 @@ export function MembershipMethodsScreen() {
}} }}
hasMore={true} hasMore={true}
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} blocksOverride={
blocksOverride={ draftFieldBlocks !== null ? draftFieldBlocks : undefined
draftFieldBlocks !== null ? draftFieldBlocks : undefined }
} policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]} showPolicyContentLockupWhenNoBlocks={
showPolicyContentLockupWhenNoBlocks={ draftFieldBlocks === null || draftFieldBlocks.length === 0
draftFieldBlocks === null || draftFieldBlocks.length === 0 }
} onFieldBlocksChange={
onFieldBlocksChange={ draftFieldBlocks === null
draftFieldBlocks === null ? undefined
? undefined : (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;
@@ -794,57 +798,52 @@ 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} blocksOverride={
blocksOverride={ draftFieldBlocks !== null ? draftFieldBlocks : undefined
draftFieldBlocks !== null ? draftFieldBlocks : undefined }
} policyMeta={state.customMethodCardMetaById?.[pendingCardId]}
policyMeta={state.customMethodCardMetaById?.[pendingCardId]} showPolicyContentLockupWhenNoBlocks={
showPolicyContentLockupWhenNoBlocks={ draftFieldBlocks === null || draftFieldBlocks.length === 0
draftFieldBlocks === null || draftFieldBlocks.length === 0 }
} onFieldBlocksChange={
onFieldBlocksChange={ draftFieldBlocks === null
draftFieldBlocks === null ? undefined
? undefined : (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 && item.compactSupportText
? item.compactSupportText
: item.supportText;
const recommended = Boolean(expanded && item.recommended);
const alternative =
!expanded && 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",
+60
View File
@@ -155,6 +155,66 @@ 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("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");
});
});