Show seven cards on the four method decks and open each as a routing overlay of its parts, so groups write from empty fields with Generate draft, search the full deck, and see unselected scope chips with enough contrast.
This commit is contained in:
1 parent
dea7dd7fbd
commit
46656f52d3
28 files changed
+1349
-318
No files matched your search
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Figma: Overlay / Modal / Card (24973:1852) with Has Steps on. A deck card
|
||||
* opens as a routing view of its parts; each row opens that field's Step
|
||||
* Modal with Generate draft on an empty field.
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import Create from "../../../components/modals/Create";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import type { ModalHeaderMenuItem } from "../../../components/modals/ModalHeader/ModalHeader.types";
|
||||
import { ValueDialogStepCard } from "./ValueDialogStepCard";
|
||||
|
||||
export type MethodCardPolicyStep = {
|
||||
id: string;
|
||||
title: string;
|
||||
emptyDescription: string;
|
||||
done: boolean;
|
||||
summary: string;
|
||||
renderEditor: () => ReactNode;
|
||||
};
|
||||
|
||||
export type MethodCardPolicyDialogProps = {
|
||||
isOpen: boolean;
|
||||
cardLabel: string;
|
||||
description?: string;
|
||||
editing: boolean;
|
||||
steps: MethodCardPolicyStep[];
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onDeselect?: () => void;
|
||||
kebabTriggerAriaLabel?: string;
|
||||
kebabMenuAriaLabel?: string;
|
||||
kebabMenuItems?: ModalHeaderMenuItem[];
|
||||
ariaLabel?: string;
|
||||
/** Customized field-blocks body. Replaces the routing steps. */
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
function MethodCardPolicyDialogComponent({
|
||||
isOpen,
|
||||
cardLabel,
|
||||
description = "",
|
||||
editing,
|
||||
steps,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onDeselect,
|
||||
kebabTriggerAriaLabel,
|
||||
kebabMenuAriaLabel,
|
||||
kebabMenuItems,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: MethodCardPolicyDialogProps) {
|
||||
const m = useMessages();
|
||||
const t = m.create.customRule.cardDialog;
|
||||
const [activeStepId, setActiveStepId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setActiveStepId(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const activeStep =
|
||||
activeStepId == null
|
||||
? null
|
||||
: (steps.find((step) => step.id === activeStepId) ?? null);
|
||||
const useCustomBody = Boolean(children);
|
||||
const showSteps = !useCustomBody && activeStep == null;
|
||||
|
||||
const handleRequestClose = useCallback(() => {
|
||||
if (activeStepId != null) {
|
||||
setActiveStepId(null);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}, [activeStepId, onClose]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (activeStepId != null) {
|
||||
setActiveStepId(null);
|
||||
return;
|
||||
}
|
||||
onConfirm();
|
||||
}, [activeStepId, onConfirm]);
|
||||
|
||||
const title = activeStep ? activeStep.title : cardLabel;
|
||||
const primaryLabel = activeStepId != null || editing ? t.save : t.addToRule;
|
||||
|
||||
return (
|
||||
<Create
|
||||
isOpen={isOpen}
|
||||
onClose={handleRequestClose}
|
||||
title={title}
|
||||
description={activeStep ? undefined : description || undefined}
|
||||
showBackButton={false}
|
||||
showCancelButton
|
||||
showNextButton
|
||||
showRemoveButton={editing && activeStepId == null}
|
||||
onCancel={handleRequestClose}
|
||||
onNext={handleNext}
|
||||
onRemove={onDeselect}
|
||||
cancelButtonText={t.cancel}
|
||||
nextButtonText={primaryLabel}
|
||||
removeButtonText={t.deselect}
|
||||
stepper={false}
|
||||
backdropVariant="blurredYellow"
|
||||
kebabTriggerAriaLabel={kebabTriggerAriaLabel}
|
||||
kebabMenuAriaLabel={kebabMenuAriaLabel}
|
||||
kebabMenuItems={
|
||||
activeStepId == null && kebabMenuItems && kebabMenuItems.length > 0
|
||||
? kebabMenuItems
|
||||
: undefined
|
||||
}
|
||||
ariaLabel={ariaLabel || cardLabel}
|
||||
>
|
||||
{useCustomBody ? (
|
||||
children
|
||||
) : showSteps ? (
|
||||
<div className="flex w-full flex-col gap-[8px]">
|
||||
{steps.map((step, index) => (
|
||||
<ValueDialogStepCard
|
||||
key={step.id}
|
||||
stepNumber={index + 1}
|
||||
stepBadgeLabel={t.stepBadge}
|
||||
title={step.title}
|
||||
description={step.done ? step.summary : step.emptyDescription}
|
||||
done={step.done}
|
||||
onClick={() => setActiveStepId(step.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
activeStep?.renderEditor()
|
||||
)}
|
||||
</Create>
|
||||
);
|
||||
}
|
||||
|
||||
MethodCardPolicyDialogComponent.displayName = "MethodCardPolicyDialog";
|
||||
|
||||
export const MethodCardPolicyDialog = memo(MethodCardPolicyDialogComponent);
|
||||
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
import IncrementerBlock from "../../../components/controls/IncrementerBlock";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { formatConflictApplicableScopeForTextarea } from "../../../../lib/create/ruleSectionsFromMethodSelections";
|
||||
import { withDecisionApproachKeyResourceScopes } from "../../../../lib/create/decisionApproachKeyResources";
|
||||
import type {
|
||||
CommunicationMethodDetailEntry,
|
||||
ConflictManagementDetailEntry,
|
||||
DecisionApproachDetailEntry,
|
||||
MembershipMethodDetailEntry,
|
||||
} from "../types";
|
||||
import ApplicableScopeField from "./ApplicableScopeField";
|
||||
import type { MethodCardPolicyStep } from "./MethodCardPolicyDialog";
|
||||
import ModalTextAreaField from "./ModalTextAreaField";
|
||||
|
||||
const CONSENSUS_LEVEL_MIN = 0;
|
||||
const CONSENSUS_LEVEL_MAX = 100;
|
||||
const CONSENSUS_LEVEL_STEP = 5;
|
||||
|
||||
function textStep(args: {
|
||||
id: string;
|
||||
title: string;
|
||||
emptyDescription: string;
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
generateDraftText?: string;
|
||||
generateDraftLabel: string;
|
||||
generateDraftHint: string;
|
||||
filledHint: string;
|
||||
placeholder: string;
|
||||
}): MethodCardPolicyStep {
|
||||
const done = args.value.trim().length > 0;
|
||||
return {
|
||||
id: args.id,
|
||||
title: args.title,
|
||||
emptyDescription: args.emptyDescription,
|
||||
done,
|
||||
summary: args.value,
|
||||
renderEditor: () => (
|
||||
<ModalTextAreaField
|
||||
hideLabel
|
||||
label={args.title}
|
||||
value={args.value}
|
||||
onChange={args.onChange}
|
||||
placeholder={args.placeholder}
|
||||
generateDraftText={args.generateDraftText}
|
||||
generateDraftLabel={args.generateDraftLabel}
|
||||
generateDraftHint={args.generateDraftHint}
|
||||
filledHint={args.filledHint}
|
||||
autoFocus
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function usePolicyFieldCopy() {
|
||||
const m = useMessages();
|
||||
return {
|
||||
dialog: m.create.customRule.cardDialog,
|
||||
draftField: m.create.draftField,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCommunicationPolicySteps(
|
||||
value: CommunicationMethodDetailEntry,
|
||||
onChange: (next: CommunicationMethodDetailEntry) => void,
|
||||
generateDraft?: CommunicationMethodDetailEntry,
|
||||
): MethodCardPolicyStep[] {
|
||||
const { dialog, draftField } = usePolicyFieldCopy();
|
||||
const headings = useMessages().create.customRule.communication.sectionHeadings;
|
||||
const fields: Array<keyof CommunicationMethodDetailEntry> = [
|
||||
"corePrinciple",
|
||||
"logisticsAdmin",
|
||||
"codeOfConduct",
|
||||
];
|
||||
return fields.map((field) =>
|
||||
textStep({
|
||||
id: field,
|
||||
title: headings[field],
|
||||
emptyDescription: dialog.emptyStepDescription,
|
||||
value: value[field],
|
||||
onChange: (next) => onChange({ ...value, [field]: next }),
|
||||
generateDraftText: generateDraft?.[field],
|
||||
generateDraftLabel: draftField.generateDraft,
|
||||
generateDraftHint: draftField.generateDraftHint,
|
||||
filledHint: dialog.filledHint,
|
||||
placeholder: draftField.placeholder,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function useMembershipPolicySteps(
|
||||
value: MembershipMethodDetailEntry,
|
||||
onChange: (next: MembershipMethodDetailEntry) => void,
|
||||
generateDraft?: MembershipMethodDetailEntry,
|
||||
): MethodCardPolicyStep[] {
|
||||
const { dialog, draftField } = usePolicyFieldCopy();
|
||||
const headings = useMessages().create.customRule.membership.sectionHeadings;
|
||||
const fields: Array<keyof MembershipMethodDetailEntry> = [
|
||||
"eligibility",
|
||||
"joiningProcess",
|
||||
"expectations",
|
||||
];
|
||||
return fields.map((field) =>
|
||||
textStep({
|
||||
id: field,
|
||||
title: headings[field],
|
||||
emptyDescription: dialog.emptyStepDescription,
|
||||
value: value[field],
|
||||
onChange: (next) => onChange({ ...value, [field]: next }),
|
||||
generateDraftText: generateDraft?.[field],
|
||||
generateDraftLabel: draftField.generateDraft,
|
||||
generateDraftHint: draftField.generateDraftHint,
|
||||
filledHint: dialog.filledHint,
|
||||
placeholder: draftField.placeholder,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function useConflictPolicySteps(
|
||||
value: ConflictManagementDetailEntry,
|
||||
onChange: (next: ConflictManagementDetailEntry) => void,
|
||||
generateDraft?: ConflictManagementDetailEntry,
|
||||
): MethodCardPolicyStep[] {
|
||||
const { dialog, draftField } = usePolicyFieldCopy();
|
||||
const t = useMessages().create.customRule.conflictManagement;
|
||||
const scopeText = formatConflictApplicableScopeForTextarea(
|
||||
value.selectedApplicableScope,
|
||||
value.applicableScope,
|
||||
);
|
||||
const generateScope =
|
||||
generateDraft && generateDraft.applicableScope.length > 0
|
||||
? generateDraft.applicableScope.join("\n")
|
||||
: generateDraft
|
||||
? formatConflictApplicableScopeForTextarea(
|
||||
generateDraft.selectedApplicableScope,
|
||||
generateDraft.applicableScope,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return [
|
||||
textStep({
|
||||
id: "corePrinciple",
|
||||
title: t.sectionHeadings.corePrinciple,
|
||||
emptyDescription: dialog.emptyStepDescription,
|
||||
value: value.corePrinciple,
|
||||
onChange: (next) => onChange({ ...value, corePrinciple: next }),
|
||||
generateDraftText: generateDraft?.corePrinciple,
|
||||
generateDraftLabel: draftField.generateDraft,
|
||||
generateDraftHint: draftField.generateDraftHint,
|
||||
filledHint: dialog.filledHint,
|
||||
placeholder: draftField.placeholder,
|
||||
}),
|
||||
textStep({
|
||||
id: "applicableScope",
|
||||
title: t.sectionHeadings.applicableScope,
|
||||
emptyDescription: t.applicableScopePlaceholder,
|
||||
value: scopeText,
|
||||
onChange: (next) => {
|
||||
const lines = next
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
onChange({
|
||||
...value,
|
||||
applicableScope: lines,
|
||||
selectedApplicableScope: [...lines],
|
||||
});
|
||||
},
|
||||
generateDraftText: generateScope,
|
||||
generateDraftLabel: draftField.generateDraft,
|
||||
generateDraftHint: draftField.generateDraftHint,
|
||||
filledHint: dialog.filledHint,
|
||||
placeholder: t.applicableScopePlaceholder,
|
||||
}),
|
||||
textStep({
|
||||
id: "processProtocol",
|
||||
title: t.sectionHeadings.processProtocol,
|
||||
emptyDescription: dialog.emptyStepDescription,
|
||||
value: value.processProtocol,
|
||||
onChange: (next) => onChange({ ...value, processProtocol: next }),
|
||||
generateDraftText: generateDraft?.processProtocol,
|
||||
generateDraftLabel: draftField.generateDraft,
|
||||
generateDraftHint: draftField.generateDraftHint,
|
||||
filledHint: dialog.filledHint,
|
||||
placeholder: draftField.placeholder,
|
||||
}),
|
||||
textStep({
|
||||
id: "restorationFallbacks",
|
||||
title: t.sectionHeadings.restorationFallbacks,
|
||||
emptyDescription: dialog.emptyStepDescription,
|
||||
value: value.restorationFallbacks,
|
||||
onChange: (next) => onChange({ ...value, restorationFallbacks: next }),
|
||||
generateDraftText: generateDraft?.restorationFallbacks,
|
||||
generateDraftLabel: draftField.generateDraft,
|
||||
generateDraftHint: draftField.generateDraftHint,
|
||||
filledHint: dialog.filledHint,
|
||||
placeholder: draftField.placeholder,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function useDecisionApproachPolicySteps(
|
||||
value: DecisionApproachDetailEntry,
|
||||
onChange: (next: DecisionApproachDetailEntry) => void,
|
||||
generateDraft?: DecisionApproachDetailEntry,
|
||||
): MethodCardPolicyStep[] {
|
||||
const { dialog, draftField } = usePolicyFieldCopy();
|
||||
const t = useMessages().create.customRule.decisionApproaches;
|
||||
const scopes = withDecisionApproachKeyResourceScopes(value.applicableScope);
|
||||
const selectedSummary = value.selectedApplicableScope.join(", ");
|
||||
|
||||
const consensusStep: MethodCardPolicyStep = {
|
||||
id: "consensusLevel",
|
||||
title: t.sectionHeadings.consensusLevel,
|
||||
emptyDescription: t.consensusLevelDisplay.supermajorityShare,
|
||||
done: true,
|
||||
summary:
|
||||
value.consensusLevel === undefined
|
||||
? t.consensusLevelDisplay.supermajorityShare
|
||||
: value.consensusLevel === 0
|
||||
? t.consensusLevelDisplay.noSetShare
|
||||
: `${value.consensusLevel}%`,
|
||||
renderEditor: () =>
|
||||
value.consensusLevel === undefined ? (
|
||||
<ModalTextAreaField
|
||||
hideLabel
|
||||
label={t.sectionHeadings.consensusLevel}
|
||||
value={t.consensusLevelDisplay.supermajorityShare}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
rows={1}
|
||||
/>
|
||||
) : (
|
||||
<IncrementerBlock
|
||||
label={t.sectionHeadings.consensusLevel}
|
||||
helpIcon={false}
|
||||
value={value.consensusLevel}
|
||||
min={CONSENSUS_LEVEL_MIN}
|
||||
max={CONSENSUS_LEVEL_MAX}
|
||||
step={CONSENSUS_LEVEL_STEP}
|
||||
onChange={(next) => onChange({ ...value, consensusLevel: next })}
|
||||
formatValue={(v) =>
|
||||
v === 0 ? t.consensusLevelDisplay.noSetShare : `${v}%`
|
||||
}
|
||||
decrementAriaLabel={dialog.decreaseConsensusLevel}
|
||||
incrementAriaLabel={dialog.increaseConsensusLevel}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const scopeStep: MethodCardPolicyStep = {
|
||||
id: "applicableScope",
|
||||
title: t.sectionHeadings.applicableScope,
|
||||
emptyDescription: dialog.scopeEmptyDescription,
|
||||
done: value.selectedApplicableScope.length > 0,
|
||||
summary: selectedSummary,
|
||||
renderEditor: () => (
|
||||
<ApplicableScopeField
|
||||
label={t.sectionHeadings.applicableScope}
|
||||
addLabel={t.scopeAddButtonLabel}
|
||||
scopes={scopes}
|
||||
selectedScopes={value.selectedApplicableScope}
|
||||
onToggleScope={(scope) =>
|
||||
onChange({
|
||||
...value,
|
||||
selectedApplicableScope: value.selectedApplicableScope.includes(
|
||||
scope,
|
||||
)
|
||||
? value.selectedApplicableScope.filter((item) => item !== scope)
|
||||
: [...value.selectedApplicableScope, scope],
|
||||
})
|
||||
}
|
||||
onAddScope={(scope) =>
|
||||
onChange({
|
||||
...value,
|
||||
applicableScope: [...value.applicableScope, scope],
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
return [
|
||||
textStep({
|
||||
id: "corePrinciple",
|
||||
title: t.sectionHeadings.corePrinciple,
|
||||
emptyDescription: dialog.emptyStepDescription,
|
||||
value: value.corePrinciple,
|
||||
onChange: (next) => onChange({ ...value, corePrinciple: next }),
|
||||
generateDraftText: generateDraft?.corePrinciple,
|
||||
generateDraftLabel: draftField.generateDraft,
|
||||
generateDraftHint: draftField.generateDraftHint,
|
||||
filledHint: dialog.filledHint,
|
||||
placeholder: draftField.placeholder,
|
||||
}),
|
||||
scopeStep,
|
||||
textStep({
|
||||
id: "stepByStepInstructions",
|
||||
title: t.sectionHeadings.stepByStepInstructions,
|
||||
emptyDescription: dialog.emptyStepDescription,
|
||||
value: value.stepByStepInstructions,
|
||||
onChange: (next) => onChange({ ...value, stepByStepInstructions: next }),
|
||||
generateDraftText: generateDraft?.stepByStepInstructions,
|
||||
generateDraftLabel: draftField.generateDraft,
|
||||
generateDraftHint: draftField.generateDraftHint,
|
||||
filledHint: dialog.filledHint,
|
||||
placeholder: draftField.placeholder,
|
||||
}),
|
||||
consensusStep,
|
||||
textStep({
|
||||
id: "objectionsDeadlocks",
|
||||
title: t.sectionHeadings.objectionsDeadlocks,
|
||||
emptyDescription: dialog.emptyStepDescription,
|
||||
value: value.objectionsDeadlocks,
|
||||
onChange: (next) => onChange({ ...value, objectionsDeadlocks: next }),
|
||||
generateDraftText: generateDraft?.objectionsDeadlocks,
|
||||
generateDraftLabel: draftField.generateDraft,
|
||||
generateDraftHint: draftField.generateDraftHint,
|
||||
filledHint: dialog.filledHint,
|
||||
placeholder: draftField.placeholder,
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
deckData,
|
||||
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;
|
||||
@@ -712,57 +717,52 @@ export function CommunicationMethodsScreen() {
|
||||
}}
|
||||
hasMore={true}
|
||||
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;
|
||||
@@ -716,57 +718,52 @@ export function ConflictManagementScreen() {
|
||||
}}
|
||||
hasMore={true}
|
||||
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;
|
||||
@@ -709,57 +710,52 @@ export function MembershipMethodsScreen() {
|
||||
}}
|
||||
hasMore={true}
|
||||
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;
|
||||
@@ -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 && item.compactSupportText
|
||||
? item.compactSupportText
|
||||
: item.supportText;
|
||||
const recommended = Boolean(expanded && item.recommended);
|
||||
const alternative =
|
||||
!expanded && item.alternative && onAlternativeSelect ? (
|
||||
<p className="w-full text-x-small-paragraph text-[var(--color-content-invert-tertiary)]">
|
||||
{alternativePrefix}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline text-[var(--color-content-invert-primary)]"
|
||||
onClick={() => onAlternativeSelect(item.alternative!.id)}
|
||||
>
|
||||
{item.alternative.label}
|
||||
</button>
|
||||
</p>
|
||||
) : null;
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<Selection
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={supportText}
|
||||
recommended={recommended}
|
||||
selected={selected}
|
||||
size={size}
|
||||
showInfoIcon={false}
|
||||
className={className}
|
||||
onClick={() => onSelect(item.id)}
|
||||
/>
|
||||
{alternative}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardStackAddCardButton({
|
||||
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)",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user