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