diff --git a/app/(app)/create/components/MethodCardPolicyDialog.tsx b/app/(app)/create/components/MethodCardPolicyDialog.tsx new file mode 100644 index 0000000..82db089 --- /dev/null +++ b/app/(app)/create/components/MethodCardPolicyDialog.tsx @@ -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(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 ( + 0 + ? kebabMenuItems + : undefined + } + ariaLabel={ariaLabel || cardLabel} + > + {useCustomBody ? ( + children + ) : showSteps ? ( +
+ {steps.map((step, index) => ( + setActiveStepId(step.id)} + /> + ))} +
+ ) : ( + activeStep?.renderEditor() + )} +
+ ); +} + +MethodCardPolicyDialogComponent.displayName = "MethodCardPolicyDialog"; + +export const MethodCardPolicyDialog = memo(MethodCardPolicyDialogComponent); diff --git a/app/(app)/create/components/methodCardPolicySteps.tsx b/app/(app)/create/components/methodCardPolicySteps.tsx new file mode 100644 index 0000000..66f62ec --- /dev/null +++ b/app/(app)/create/components/methodCardPolicySteps.tsx @@ -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: () => ( + + ), + }; +} + +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 = [ + "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 = [ + "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 ? ( + {}} + disabled + rows={1} + /> + ) : ( + 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: () => ( + + 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, + }), + ]; +} diff --git a/app/(app)/create/hooks/useFacetRecommendations.ts b/app/(app)/create/hooks/useFacetRecommendations.ts index d784bf2..5213c80 100644 --- a/app/(app)/create/hooks/useFacetRecommendations.ts +++ b/app/(app)/create/hooks/useFacetRecommendations.ts @@ -14,7 +14,10 @@ import { deckData, recommendDeck, } from "../../../../lib/create/deckRecommend"; -import type { GroupAnswers } from "../../../../lib/create/deckTypes"; +import type { + GroupAnswers, + RecommendedCard, +} from "../../../../lib/create/deckTypes"; /** * Card-deck section ids served by `/api/create-flow/methods` (CR-88 §9.2). @@ -143,8 +146,13 @@ export function deriveCompactCards( compactCardIds: string[]; recommendedIds: Set; orderedIds: string[]; + fallback: boolean; + recommendedById: Map; } { const recommendation = recommendDeck(deckData(section), answers, { limit }); + const recommendedById = new Map( + recommendation.recommended.map((card) => [card.id, card]), + ); const orderedIds = recommendation.fallback ? recommendation.seeAll : [ @@ -156,6 +164,8 @@ export function deriveCompactCards( compactCardIds: orderedIds.slice(0, limit), recommendedIds: new Set(), orderedIds, + fallback: true, + recommendedById, }; } return { @@ -166,6 +176,8 @@ export function deriveCompactCards( .map((card) => card.id), ), orderedIds, + fallback: false, + recommendedById, }; } diff --git a/app/(app)/create/hooks/useMethodCardDeckOrdering.ts b/app/(app)/create/hooks/useMethodCardDeckOrdering.ts index 32ccd21..19ee365 100644 --- a/app/(app)/create/hooks/useMethodCardDeckOrdering.ts +++ b/app/(app)/create/hooks/useMethodCardDeckOrdering.ts @@ -2,12 +2,15 @@ import { useMemo } from "react"; import { mergeCompactCardIdsWithPinnedSelected } from "../../../../lib/create/methodCardDisplayOrder"; +import { formatRecommendationWhyLine } from "../../../../lib/create/formatRecommendationWhyLine"; import { deriveCompactCards, groupAnswersForRecommendations, type RecommendationSection, } from "./useFacetRecommendations"; +import { deckData } from "../../../../lib/create/deckRecommend"; import { useCreateFlow } from "../context/CreateFlowContext"; +import { useMessages } from "../../../contexts/MessagesContext"; type MethodEntry = { id: string; label: string; supportText: string }; @@ -25,9 +28,8 @@ function orderByIds( } /** - * Deck order and the compact stack come from the job rule. Current screens - * show five cards; a deck screen passes seven. Selected cards still pin - * into the compact stack. + * Deck order and the compact stack come from the job rule. Compact shows + * at most seven recommended cards. Selected cards still pin into the stack. */ export function useMethodCardDeckOrdering( section: RecommendationSection, @@ -35,13 +37,15 @@ export function useMethodCardDeckOrdering( selectedIds: readonly string[], ) { const { state } = useCreateFlow(); + const copy = useMessages().create.customRule.recommendations; + const compactLimit = deckData(section).config.displayLimit; const answers = useMemo( () => groupAnswersForRecommendations(state), [state], ); const deck = useMemo( - () => deriveCompactCards(section, answers, 5), - [section, answers], + () => deriveCompactCards(section, answers, compactLimit), + [section, answers, compactLimit], ); const rankedMethods = useMemo( @@ -59,25 +63,57 @@ export function useMethodCardDeckOrdering( deck.compactCardIds, selectedIds, selectionShowcaseActive, - 5, + compactLimit, ), [ rankedMethods, deck.compactCardIds, selectedIds, selectionShowcaseActive, + compactLimit, ], ); + const compactIdSet = useMemo( + () => new Set(compactCardIds), + [compactCardIds], + ); + const sampleCards = useMemo( () => - rankedMethods.map((entry) => ({ - id: entry.id, - label: entry.label, - supportText: entry.supportText, - recommended: recommendedIds.has(entry.id), - })), - [rankedMethods, recommendedIds], + rankedMethods.map((entry) => { + const recommendation = deck.recommendedById.get(entry.id); + const whyLine = + recommendation && recommendation.tag !== "everyRuleHas" + ? formatRecommendationWhyLine(recommendation.reason, answers, copy) + : ""; + const alternativeId = recommendation?.alternative; + const alternativeMethod = alternativeId + ? rankedMethods.find((method) => method.id === alternativeId) ?? + methods.find((method) => method.id === alternativeId) + : undefined; + return { + id: entry.id, + label: entry.label, + supportText: entry.supportText, + compactSupportText: + whyLine && compactIdSet.has(entry.id) ? whyLine : undefined, + recommended: recommendedIds.has(entry.id), + alternative: + alternativeMethod && compactIdSet.has(entry.id) + ? { id: alternativeMethod.id, label: alternativeMethod.label } + : undefined, + }; + }), + [ + answers, + compactIdSet, + copy, + deck.recommendedById, + methods, + rankedMethods, + recommendedIds, + ], ); const methodById = useMemo( @@ -92,6 +128,8 @@ export function useMethodCardDeckOrdering( recommendedIds, sampleCards, methodById, + showDeckLine: !deck.fallback, + compactLimit, recommendationsReady: true, }; } diff --git a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx index 3c67840..64aa4d5 100644 --- a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx +++ b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx @@ -8,11 +8,11 @@ * ({@link CreateFlowTwoColumnSelectShell}); options are {@link CardStack} with * `layout="singleStack"` and {@link Selection} size `l`. * - * Card click opens the Figma create modal (node `20246-15829`) with three - * editable sections rendered by {@link CommunicationMethodEditFields}. The - * primary action is **Add Platform** for an unselected card and **Save** for a - * selected card. **Remove** is a danger footer button on selected cards (and - * remains in the kebab) via {@link removeMethodCardFromFacetSelection}. + * Card click opens Overlay / Modal / Card as a routing view of its parts + * ({@link MethodCardPolicyDialog}). The primary action is **Add to Rule** for + * an unselected card and **Save** for a selected card. **Deselect** is the + * footer on selected cards (**Remove** stays in the kebab) via + * {@link removeMethodCardFromFacetSelection}. */ import { useState, useCallback, useMemo, useRef } from "react"; @@ -24,9 +24,9 @@ import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; import CardStack from "../../../../components/cards/CardStack"; -import Create from "../../../../components/modals/Create"; import InlineTextButton from "../../../../components/buttons/InlineTextButton"; -import { CommunicationMethodEditFields } from "../../components/methodEditFields"; +import { MethodCardPolicyDialog } from "../../components/MethodCardPolicyDialog"; +import { useCommunicationPolicySteps } from "../../components/methodCardPolicySteps"; import CustomMethodCardWizard from "../../components/CustomMethodCardWizard"; import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer"; import { communicationPresetFor, emptyCommunicationDetails } from "../../../../../lib/create/finalReviewChipPresets"; @@ -105,7 +105,8 @@ export function CommunicationMethodsScreen() { [comm.methods, selectedIds, state.customMethodCardMetaById], ); - const { sampleCards, compactCardIds, methodById, recommendationsReady } = + const rec = m.create.customRule.recommendations; + const { sampleCards, compactCardIds, methodById, recommendationsReady, showDeckLine, compactLimit } = useMethodCardDeckOrdering( "communication", mergedMethods, @@ -188,10 +189,14 @@ export function CommunicationMethodsScreen() { }, [markCreateFlowInteraction], ); + const communicationSteps = useCommunicationPolicySteps( + pendingDraft ?? emptyCommunicationDetails(), + handleDraftChange, + pendingCardId ? communicationPresetFor(pendingCardId) : undefined, + ); const isSelectedCardModal = pendingCardId !== null && selectedIds.includes(pendingCardId); - const showMethodModalPrimary = true; const customFacetDetailsMatchPreset = useMemo(() => { if (!pendingCardId || !pendingDraft) return false; @@ -710,59 +715,54 @@ export function CommunicationMethodsScreen() { markCreateFlowInteraction(); setExpanded((prev) => !prev); }} - hasMore={true} + hasMore={sampleCards.length > compactLimit} toggleLabel={comm.page.seeAllLink} - compactRecommendedLimit={5} + compactRecommendedLimit={compactLimit} compactCardIds={compactCardIds} layout="singleStack" + deckLine={showDeckLine ? rec.deckLine : undefined} + searchPlaceholder={rec.searchPlaceholder} + alternativePrefix={rec.alternative} + onAlternativeSelect={handleCardClick} className="w-full lg:h-full lg:min-h-0 lg:flex-1" /> )} - - {pendingCardId && pendingDraft ? ( - modalUsesWizardFieldBlocksBody ? ( - setDraftFieldBlocks(next) - } - /> - ) : ( - - ) + {pendingCardId && modalUsesWizardFieldBlocksBody ? ( + setDraftFieldBlocks(next) + } + /> ) : null} - + { if (!pendingCardId || !pendingDraft) return false; @@ -714,59 +716,54 @@ export function ConflictManagementScreen() { markCreateFlowInteraction(); setExpanded((prev) => !prev); }} - hasMore={true} + hasMore={sampleCards.length > compactLimit} toggleLabel={cm.page.seeAllLink} - compactRecommendedLimit={5} + compactRecommendedLimit={compactLimit} compactCardIds={compactCardIds} layout="singleStack" + deckLine={showDeckLine ? rec.deckLine : undefined} + searchPlaceholder={rec.searchPlaceholder} + alternativePrefix={rec.alternative} + onAlternativeSelect={handleCardClick} className="w-full lg:h-full lg:min-h-0 lg:flex-1" /> )} - - {pendingCardId && pendingDraft ? ( - modalUsesWizardFieldBlocksBody ? ( - setDraftFieldBlocks(next) - } - /> - ) : ( - - ) + {pendingCardId && modalUsesWizardFieldBlocksBody ? ( + setDraftFieldBlocks(next) + } + /> ) : null} - + { if (!pendingCardId || !pendingDraft) return false; @@ -707,59 +708,54 @@ export function MembershipMethodsScreen() { markCreateFlowInteraction(); setExpanded((prev) => !prev); }} - hasMore={true} + hasMore={sampleCards.length > compactLimit} toggleLabel={mem.page.seeAllLink} - compactRecommendedLimit={5} + compactRecommendedLimit={compactLimit} compactCardIds={compactCardIds} layout="singleStack" + deckLine={showDeckLine ? rec.deckLine : undefined} + searchPlaceholder={rec.searchPlaceholder} + alternativePrefix={rec.alternative} + onAlternativeSelect={handleCardClick} className="w-full lg:h-full lg:min-h-0 lg:flex-1" /> )} - - {pendingCardId && pendingDraft ? ( - modalUsesWizardFieldBlocksBody ? ( - setDraftFieldBlocks(next) - } - /> - ) : ( - - ) + {pendingCardId && modalUsesWizardFieldBlocksBody ? ( + setDraftFieldBlocks(next) + } + /> ) : null} - + { if (!pendingCardId || !pendingDraft) return false; @@ -776,7 +780,7 @@ export function DecisionApproachesScreen() { onCardSelect={handleCardSelect} expanded={expanded} onToggleExpand={handleToggleExpand} - hasMore={true} + hasMore={sampleCards.length > compactLimit} toggleLabel={da.cardStack.toggleSeeAll} showLessLabel={da.cardStack.toggleShowLess} title="" @@ -794,57 +798,52 @@ export function DecisionApproachesScreen() { ) } layout="singleStack" - compactRecommendedLimit={5} + compactRecommendedLimit={compactLimit} compactCardIds={compactCardIds} + deckLine={showDeckLine ? rec.deckLine : undefined} + searchPlaceholder={rec.searchPlaceholder} + alternativePrefix={rec.alternative} + onAlternativeSelect={handleCardSelect} className="w-full lg:h-full lg:min-h-0 lg:flex-1" headerLockupSize={mdUp ? "L" : "M"} /> )} - - {pendingCardId && pendingDraft ? ( - modalUsesWizardFieldBlocksBody ? ( - setDraftFieldBlocks(next) - } - /> - ) : ( - - ) + {pendingCardId && modalUsesWizardFieldBlocksBody ? ( + setDraftFieldBlocks(next) + } + /> ) : null} - + ( title = "", description = "", layout = "default", - compactRecommendedLimit = 5, + compactRecommendedLimit = 7, compactCardIds, compactDesktopLayout: compactDesktopLayoutProp = "grid", headerLockupSize, @@ -39,16 +39,25 @@ const CardStackContainer = memo( addCardLabel = "", addCardAriaLabel = "", onAddCard, + deckLine = "", + searchPlaceholder = "", + alternativePrefix = "", + onAlternativeSelect, }) => { const t = useTranslation("controlsChrome"); const [internalExpanded, setInternalExpanded] = useState(false); const [internalSelectedIds, setInternalSelectedIds] = useState( [], ); + const [searchQuery, setSearchQuery] = useState(""); const expanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded; + useEffect(() => { + if (!expanded) setSearchQuery(""); + }, [expanded]); + const handleToggleExpand = useCallback(() => { if (controlledOnToggleExpand) { controlledOnToggleExpand(); @@ -102,6 +111,12 @@ const CardStackContainer = memo( addCardLabel={addCardLabel} addCardAriaLabel={addCardAriaLabel} onAddCard={onAddCard} + deckLine={deckLine} + searchPlaceholder={searchPlaceholder} + searchQuery={searchQuery} + onSearchQueryChange={setSearchQuery} + alternativePrefix={alternativePrefix} + onAlternativeSelect={onAlternativeSelect} /> ); }, diff --git a/app/components/cards/CardStack/CardStack.types.ts b/app/components/cards/CardStack/CardStack.types.ts index 68795e6..abfa5e1 100644 --- a/app/components/cards/CardStack/CardStack.types.ts +++ b/app/components/cards/CardStack/CardStack.types.ts @@ -6,6 +6,10 @@ export interface CardStackItem { label: string; supportText?: string; recommended?: boolean; + /** Compact-seven why line; See all keeps `supportText`. */ + compactSupportText?: string; + /** One alternative per job, shown under the compact card. */ + alternative?: { id: string; label: string }; } export interface CardStackProps { @@ -23,9 +27,16 @@ export interface CardStackProps { /** "default" = compact grid/column + expanded 2-col 142px tiles; "singleStack" = always one column, expand shows more in same stack */ layout?: "default" | "singleStack"; /** - * Max recommended cards in compact (non-expanded) mode. Default 5; Figma compact stack uses 3. + * Max recommended cards in compact (non-expanded) mode. Default 7. */ compactRecommendedLimit?: number; + /** Line above the compact seven, in place of a tag on every card. */ + deckLine?: string; + /** See-all search field placeholder. */ + searchPlaceholder?: string; + /** Prefix for the compact alternative row. */ + alternativePrefix?: string; + onAlternativeSelect?: (id: string) => void; /** * Optional explicit list of card ids to render in the compact slot, in * order. When provided, this overrides the default @@ -78,4 +89,10 @@ export interface CardStackViewProps { addCardLabel: string; addCardAriaLabel: string; onAddCard?: () => void; + deckLine: string; + searchPlaceholder: string; + searchQuery: string; + onSearchQueryChange: (value: string) => void; + alternativePrefix: string; + onAlternativeSelect?: (id: string) => void; } diff --git a/app/components/cards/CardStack/CardStack.view.tsx b/app/components/cards/CardStack/CardStack.view.tsx index d6fbf29..9478149 100644 --- a/app/components/cards/CardStack/CardStack.view.tsx +++ b/app/components/cards/CardStack/CardStack.view.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from "react"; import HeaderLockup from "../../type/HeaderLockup"; import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup.types"; import Selection from "../Selection"; -import type { CardStackViewProps } from "./CardStack.types"; +import type { CardStackItem, CardStackViewProps } from "./CardStack.types"; /** Figma Card / CardSelection on Compact + Expanded Card Stack (`281×142`). */ const FIGMA_CARD_SELECTION_TILE_CLASS = @@ -38,6 +38,87 @@ function CardStackHeaderLockup({ ); } +function matchesSearch(item: CardStackItem, query: string): boolean { + if (!query) return true; + const haystack = `${item.label} ${item.supportText ?? ""}`.toLowerCase(); + return haystack.includes(query.toLowerCase()); +} + +function SearchGlyph() { + return ( + + + + + ); +} + +function StackCard({ + item, + expanded, + selected, + size, + className, + onSelect, + onAlternativeSelect, + alternativePrefix, +}: { + item: CardStackItem; + expanded: boolean; + selected: boolean; + size: "m" | "l"; + className?: string; + onSelect: (id: string) => void; + onAlternativeSelect?: (id: string) => void; + alternativePrefix: string; +}) { + const supportText = + !expanded && !selected && item.compactSupportText + ? item.compactSupportText + : item.supportText; + const recommended = Boolean(expanded && item.recommended); + const alternative = + !expanded && !selected && item.alternative && onAlternativeSelect ? ( +

+ {alternativePrefix}{" "} + +

+ ) : null; + return ( +
+ onSelect(item.id)} + /> + {alternative} +
+ ); +} + function CardStackAddCardButton({ label, ariaLabel, @@ -98,6 +179,12 @@ export function CardStackView({ addCardLabel, addCardAriaLabel, onAddCard, + deckLine, + searchPlaceholder, + searchQuery, + onSearchQueryChange, + alternativePrefix, + onAlternativeSelect, }: CardStackViewProps) { const addTile = showAddCard && onAddCard && addCardLabel.length > 0 ? ( @@ -130,7 +217,9 @@ export function CardStackView({ // At lg+ the cards scroll inside this column and the See all / Show less // control stays pinned below (Figma Flow — Right Rail). if (layout === "singleStack") { - const displayedCards = expanded ? cards : compactCards; + const displayedCards = (expanded ? cards : compactCards).filter((item) => + expanded ? matchesSearch(item, searchQuery) : true, + ); return (
+ {!expanded && deckLine ? ( +

+ {deckLine} +

+ ) : null} + {expanded && searchPlaceholder ? ( +
+ 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)]" + /> + +
+ ) : null}
{displayedCards.map((item) => ( - onCardSelect(item.id)} + onSelect={onCardSelect} + onAlternativeSelect={onAlternativeSelect} + alternativePrefix={alternativePrefix} /> ))} {addTile} diff --git a/app/components/controls/Chip/Chip.view.tsx b/app/components/controls/Chip/Chip.view.tsx index 09f5b4d..1f1a394 100644 --- a/app/components/controls/Chip/Chip.view.tsx +++ b/app/components/controls/Chip/Chip.view.tsx @@ -72,7 +72,7 @@ function chipSurfaceStyle( return paint( "transparent", "var(--color-content-default-brand-primary)", - "var(--color-border-default-control)", + "var(--color-content-default-tertiary)", ); } diff --git a/lib/create/decisionApproachKeyResources.ts b/lib/create/decisionApproachKeyResources.ts index 10ef483..b3910ed 100644 --- a/lib/create/decisionApproachKeyResources.ts +++ b/lib/create/decisionApproachKeyResources.ts @@ -95,14 +95,24 @@ export function stringArraysEqual( return a.every((value, index) => value === b[index]); } +export function decisionApproachDefaultScopeChips(): string[] { + const chips = ( + decisionApproachesMessages as { defaultScopeChips?: unknown } + ).defaultScopeChips; + if (!Array.isArray(chips)) return []; + return chips.filter((value): value is string => typeof value === "string"); +} + /** - * Offer the four sidebar labels as Applicable Scope chips without persisting - * them onto `applicableScope` (unchecked keys must not publish as defaults). + * Offer the signed-off deck chips, then this card's scopes, then the coverage + * checklist labels. Unchecked extras are display-only and must not publish + * as defaults. */ export function withDecisionApproachKeyResourceScopes( scopes: readonly string[], ): string[] { return uniquePreserveOrder([ + ...decisionApproachDefaultScopeChips(), ...scopes, ...decisionApproachKeyResourceLabels(), ]); diff --git a/lib/create/formatRecommendationWhyLine.ts b/lib/create/formatRecommendationWhyLine.ts new file mode 100644 index 0000000..26c67dd --- /dev/null +++ b/lib/create/formatRecommendationWhyLine.ts @@ -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; + size: Record; + }; + answerThirdPerson: Record; + answerYouSaid: Record; +}; + +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 { + 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); +} diff --git a/messages/en/create/customRule/cardDialog.json b/messages/en/create/customRule/cardDialog.json new file mode 100644 index 0000000..5dbafb3 --- /dev/null +++ b/messages/en/create/customRule/cardDialog.json @@ -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" +} diff --git a/messages/en/create/customRule/decisionApproaches.json b/messages/en/create/customRule/decisionApproaches.json index c23ff01..52564da 100644 --- a/messages/en/create/customRule/decisionApproaches.json +++ b/messages/en/create/customRule/decisionApproaches.json @@ -68,6 +68,12 @@ "noSetShare": "No set share" }, "scopeAddButtonLabel": "Add a scope", + "defaultScopeChips": [ + "Changing the Rule", + "Money", + "Projects", + "Asking someone to leave" + ], "methods": [ { "id": "decision-speeds", diff --git a/messages/en/create/customRule/recommendations.json b/messages/en/create/customRule/recommendations.json index e7304ac..b65f865 100644 --- a/messages/en/create/customRule/recommendations.json +++ b/messages/en/create/customRule/recommendations.json @@ -1,6 +1,7 @@ { - "_comment": "Create flow, recommendation lines on each deck (Figma CR-291, deck seven board 24716:27074 and card treatments 24717:27255). Draft copy. Dani signs off the why lines and the alternative row.", - "deckLine": "Recommended for your group", + "_comment": "Create flow, recommendation lines on each deck (Figma deck seven board 25019:114405 and card treatments 25019:114480). Draft copy. Dani signs off the why lines and the alternative row.", + "deckLine": "Recommended for your community", + "searchPlaceholder": "Search this deck", "tags": { "recommended": "RECOMMENDED", "everyRuleHas": "Every Rule has" @@ -47,5 +48,5 @@ "records": "you keep information about the people you serve", "risky": "your work carries physical risk, or needs a license, permit or inspection" }, - "alternative": "Another way groups do this" + "alternative": "Another way communities do this:" } diff --git a/messages/en/index.ts b/messages/en/index.ts index e2af268..42e82ec 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -59,6 +59,8 @@ import createDecisionApproaches from "./create/customRule/decisionApproaches.jso import createConflictManagement from "./create/customRule/conflictManagement.json"; import createCustomMethodCardWizard from "./create/customRule/customMethodCardWizard.json"; import createModalKebabMenu from "./create/customRule/modalKebabMenu.json"; +import createCardDialog from "./create/customRule/cardDialog.json"; +import createRecommendations from "./create/customRule/recommendations.json"; // create – stage 3: reviewAndComplete import createConfirmStakeholders from "./create/reviewAndComplete/confirmStakeholders.json"; @@ -133,6 +135,8 @@ export default { conflictManagement: createConflictManagement, customMethodCardWizard: createCustomMethodCardWizard, modalKebabMenu: createModalKebabMenu, + cardDialog: createCardDialog, + recommendations: createRecommendations, }, reviewAndComplete: { confirmStakeholders: createConfirmStakeholders, diff --git a/tests/components/Chip.test.tsx b/tests/components/Chip.test.tsx index b405e36..5bfb662 100644 --- a/tests/components/Chip.test.tsx +++ b/tests/components/Chip.test.tsx @@ -54,7 +54,7 @@ describe("Chip", () => { expect(chip.style.color).toContain("--color-content-default-brand-primary"); expect(chip.style.backgroundColor).toBe("transparent"); expect(chip.style.borderColor).toContain( - "--color-border-default-control", + "--color-content-default-tertiary", ); }); diff --git a/tests/components/CommunicationMethodsScreenPersistence.test.tsx b/tests/components/CommunicationMethodsScreenPersistence.test.tsx index 745ccc8..d2a5f61 100644 --- a/tests/components/CommunicationMethodsScreenPersistence.test.tsx +++ b/tests/components/CommunicationMethodsScreenPersistence.test.tsx @@ -62,6 +62,27 @@ function ScreenWithStateProbe({ * snapping back to preset copy. See {@link CommunicationMethodEditFields} * and `buildPublishPayload` for the read side. */ +const COMMUNICATION_STEPS = [ + "Core principle and scope", + "How it works", + "Norms", +] as const; + +function openPolicyStep(dialog: HTMLElement, name: string) { + fireEvent.click(within(dialog).getByRole("button", { name })); +} + +function generateAllCommunicationSteps(dialog: HTMLElement) { + for (const name of COMMUNICATION_STEPS) { + openPolicyStep(dialog, name); + const generate = within(dialog).queryByRole("button", { + name: "Generate draft", + }); + if (generate) fireEvent.click(generate); + fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); + } +} + describe("CommunicationMethodsScreen — Add card persistence", () => { it("starts empty and Generate draft inserts the catalog preset", async () => { let latest: CreateFlowState = {}; @@ -79,24 +100,24 @@ describe("CommunicationMethodsScreen — Add card persistence", () => { const dialog = await screen.findByRole("dialog"); expect(within(dialog).queryByAltText("Help")).not.toBeInTheDocument(); - const textboxes = within(dialog).getAllByRole("textbox"); - expect(textboxes.length).toBe(3); - const corePrincipleField = textboxes[0] as HTMLTextAreaElement; + openPolicyStep(dialog, "Core principle and scope"); + const corePrincipleField = within(dialog).getByRole( + "textbox", + ) as HTMLTextAreaElement; expect(corePrincipleField.value).toBe(""); - const generateButtons = within(dialog).getAllByRole("button", { - name: "Generate draft", - }); - expect(generateButtons.length).toBe(3); - fireEvent.click(generateButtons[0]); + fireEvent.click( + within(dialog).getByRole("button", { name: "Generate draft" }), + ); expect(corePrincipleField.value).toBe( communicationPresetFor("signal").corePrinciple, ); fireEvent.change(corePrincipleField, { target: { value: "Custom principle" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); fireEvent.click( within(dialog).getByRole("button", { - name: "Add card", + name: "Add to Rule", }), ); @@ -160,13 +181,11 @@ describe("CommunicationMethodsScreen — Add card persistence", () => { screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0], ); const dialog = await screen.findByRole("dialog"); - const textareas = within(dialog).getAllByRole( - "textbox", - ) as HTMLTextAreaElement[]; - expect(textareas.length).toBe(3); - expect(textareas[0].value).toBe("Saved principle"); - expect(textareas[1].value).toBe("Saved logistics"); - expect(textareas[2].value).toBe("Saved coc"); + expect(within(dialog).getByText("Saved principle")).toBeInTheDocument(); + expect(within(dialog).getByText("Saved logistics")).toBeInTheDocument(); + expect(within(dialog).getByText("Saved coc")).toBeInTheDocument(); + openPolicyStep(dialog, "Core principle and scope"); + expect(within(dialog).getByRole("textbox")).toHaveValue("Saved principle"); }); it("persists edits and closes when Save is clicked on an already-selected platform", async () => { @@ -193,10 +212,11 @@ describe("CommunicationMethodsScreen — Add card persistence", () => { screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0], ); const dialog = await screen.findByRole("dialog"); - const textareas = within(dialog).getAllByRole( - "textbox", - ) as HTMLTextAreaElement[]; - fireEvent.change(textareas[0], { target: { value: "Edited principle" } }); + openPolicyStep(dialog, "Core principle and scope"); + fireEvent.change(within(dialog).getByRole("textbox"), { + target: { value: "Edited principle" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); await waitFor(() => { @@ -239,14 +259,14 @@ describe("CommunicationMethodsScreen — Add card persistence", () => { })[0], ); const dialog = await screen.findByRole("dialog"); - const textareas = within(dialog).getAllByRole( - "textbox", - ) as HTMLTextAreaElement[]; - expect(textareas).toHaveLength(3); - expect(textareas[0].value).toBe(details.corePrinciple); - expect(textareas[1].value).toBe(details.logisticsAdmin); - expect(textareas[2].value).toBe(details.codeOfConduct); expect(within(dialog).queryByText(noFieldsHint)).not.toBeInTheDocument(); + expect(within(dialog).getByText(details.corePrinciple)).toBeInTheDocument(); + expect(within(dialog).getByText(details.logisticsAdmin)).toBeInTheDocument(); + expect(within(dialog).getByText(details.codeOfConduct)).toBeInTheDocument(); + openPolicyStep(dialog, "Core principle and scope"); + expect(within(dialog).getByRole("textbox")).toHaveValue( + details.corePrinciple, + ); }); it("opens meaning fields editable without Customize", async () => { @@ -262,14 +282,12 @@ describe("CommunicationMethodsScreen — Add card persistence", () => { screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0], ); const dialog = await screen.findByRole("dialog"); - const textareas = within(dialog).getAllByRole( - "textbox", - ) as HTMLTextAreaElement[]; - expect(textareas[0]).not.toBeDisabled(); fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); expect( screen.getByRole("menuitem", { name: "Customize" }), ).toBeInTheDocument(); + openPolicyStep(dialog, "Core principle and scope"); + expect(within(dialog).getByRole("textbox")).not.toBeDisabled(); }); it("closing the wizard without Finalize leaves the card modal and does not persist", async () => { @@ -347,11 +365,12 @@ describe("CommunicationMethodsScreen — Add card persistence", () => { screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0], ); const dialog = await screen.findByRole("dialog"); - const textboxes = within(dialog).getAllByRole( - "textbox", - ) as HTMLTextAreaElement[]; - fireEvent.change(textboxes[0], { target: { value: "Edited principle" } }); + openPolicyStep(dialog, "Core principle and scope"); + fireEvent.change(within(dialog).getByRole("textbox"), { + target: { value: "Edited principle" }, + }); + fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "Escape" }); const keepEditing = await screen.findByRole("button", { name: "Keep editing", @@ -390,10 +409,11 @@ describe("CommunicationMethodsScreen — Add card persistence", () => { screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0], ); const dialog = await screen.findByRole("dialog"); - const textboxes = within(dialog).getAllByRole( - "textbox", - ) as HTMLTextAreaElement[]; - fireEvent.change(textboxes[0], { target: { value: "Edited principle" } }); + openPolicyStep(dialog, "Core principle and scope"); + fireEvent.change(within(dialog).getByRole("textbox"), { + target: { value: "Edited principle" }, + }); + fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "Escape" }); await confirmDiscardCustomizeEdits(); @@ -473,11 +493,7 @@ describe("CommunicationMethodsScreen — Add card persistence", () => { screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0], ); const dialog = await screen.findByRole("dialog"); - for (const button of within(dialog).getAllByRole("button", { - name: "Generate draft", - })) { - fireEvent.click(button); - } + generateAllCommunicationSteps(dialog); fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); @@ -619,10 +635,10 @@ describe("CommunicationMethodsScreen — Add card persistence", () => { screen.getAllByRole("button", { name: /Signal: We coordinate in encrypted Signal groups/ })[0], ); const dialog = await screen.findByRole("dialog"); - const textboxes = within(dialog).getAllByRole( - "textbox", - ) as HTMLTextAreaElement[]; - fireEvent.change(textboxes[0], { target: { value: "Unsaved principle" } }); + openPolicyStep(dialog, "Core principle and scope"); + fireEvent.change(within(dialog).getByRole("textbox"), { + target: { value: "Unsaved principle" }, + }); expect(dispatchBeforeUnload()).toBe(true); }); diff --git a/tests/components/ConflictManagementScreen.test.tsx b/tests/components/ConflictManagementScreen.test.tsx index b910259..dea8131 100644 --- a/tests/components/ConflictManagementScreen.test.tsx +++ b/tests/components/ConflictManagementScreen.test.tsx @@ -22,13 +22,8 @@ describe("ConflictManagementScreen", () => { })[0], ); const dialog = await screen.findByRole("dialog"); - const fields = within(dialog).getAllByRole("textbox"); - expect(fields.length).toBeGreaterThan(0); - for (const field of fields) { - expect(field).toBeEnabled(); - } expect( - within(dialog).queryByRole("button", { name: "Remove" }), + within(dialog).queryByRole("button", { name: "Deselect" }), ).not.toBeInTheDocument(); fireEvent.click( within(dialog).getByRole("button", { name: "More options" }), @@ -36,5 +31,13 @@ describe("ConflictManagementScreen", () => { expect( screen.getByRole("menuitem", { name: "Customize" }), ).toBeInTheDocument(); + fireEvent.click( + within(dialog).getByRole("button", { name: "Core principle" }), + ); + const fields = within(dialog).getAllByRole("textbox"); + expect(fields.length).toBeGreaterThan(0); + for (const field of fields) { + expect(field).toBeEnabled(); + } }); }); diff --git a/tests/components/MembershipMethodsScreen.test.tsx b/tests/components/MembershipMethodsScreen.test.tsx index 507c145..25291d3 100644 --- a/tests/components/MembershipMethodsScreen.test.tsx +++ b/tests/components/MembershipMethodsScreen.test.tsx @@ -22,13 +22,8 @@ describe("MembershipMethodsScreen", () => { })[0], ); const dialog = await screen.findByRole("dialog"); - const fields = within(dialog).getAllByRole("textbox"); - expect(fields.length).toBeGreaterThan(0); - for (const field of fields) { - expect(field).toBeEnabled(); - } expect( - within(dialog).queryByRole("button", { name: "Remove" }), + within(dialog).queryByRole("button", { name: "Deselect" }), ).not.toBeInTheDocument(); fireEvent.click( within(dialog).getByRole("button", { name: "More options" }), @@ -36,5 +31,13 @@ describe("MembershipMethodsScreen", () => { expect( screen.getByRole("menuitem", { name: "Customize" }), ).toBeInTheDocument(); + fireEvent.click( + within(dialog).getByRole("button", { name: "Who it covers and why" }), + ); + const fields = within(dialog).getAllByRole("textbox"); + expect(fields.length).toBeGreaterThan(0); + for (const field of fields) { + expect(field).toBeEnabled(); + } }); }); diff --git a/tests/components/MethodCardPolicyDialog.test.tsx b/tests/components/MethodCardPolicyDialog.test.tsx new file mode 100644 index 0000000..a9a2f1e --- /dev/null +++ b/tests/components/MethodCardPolicyDialog.test.tsx @@ -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: () => ( +