Edit flow configured
This commit is contained in:
@@ -7,7 +7,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
||||||
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
||||||
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
|
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
|
||||||
@@ -15,7 +15,7 @@ import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
|
|||||||
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
|
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
|
||||||
import CreateFlowFooter from "../../components/navigation/CreateFlowFooter";
|
import CreateFlowFooter from "../../components/navigation/CreateFlowFooter";
|
||||||
import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav";
|
import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav";
|
||||||
import { getNextStep, getStepIndex } from "./utils/flowSteps";
|
import { getNextStep, getStepIndex, parseReviewReturnSearchParam, CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "./utils/flowSteps";
|
||||||
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
||||||
import {
|
import {
|
||||||
createFlowStepUsesCenteredTextLayout,
|
createFlowStepUsesCenteredTextLayout,
|
||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
} from "./utils/createFlowFooterClassNames";
|
} from "./utils/createFlowFooterClassNames";
|
||||||
import {
|
import {
|
||||||
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
|
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
|
||||||
|
methodCardFacetSectionForConfirmStep,
|
||||||
type CustomRuleConfirmFooterStep,
|
type CustomRuleConfirmFooterStep,
|
||||||
} from "./utils/customRuleConfirmFooterSteps";
|
} from "./utils/customRuleConfirmFooterSteps";
|
||||||
import { getDefaultFooterLabel } from "./utils/createFlowFooterLabels";
|
import { getDefaultFooterLabel } from "./utils/createFlowFooterLabels";
|
||||||
@@ -111,6 +112,8 @@ function CreateFlowLayoutContent({
|
|||||||
const tLogin = useTranslation("pages.login");
|
const tLogin = useTranslation("pages.login");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const reviewReturnTarget = parseReviewReturnSearchParam(searchParams);
|
||||||
const { openLogin } = useAuthModal();
|
const { openLogin } = useAuthModal();
|
||||||
const skipCommunitySave = sessionResolved && Boolean(sessionUser);
|
const skipCommunitySave = sessionResolved && Boolean(sessionUser);
|
||||||
const {
|
const {
|
||||||
@@ -123,7 +126,7 @@ function CreateFlowLayoutContent({
|
|||||||
} = useCreateFlowNavigation(
|
} = useCreateFlowNavigation(
|
||||||
skipCommunitySave ? { skipCommunitySave: true } : undefined,
|
skipCommunitySave ? { skipCommunitySave: true } : undefined,
|
||||||
);
|
);
|
||||||
const { state, clearState, updateState, resetCustomRuleSelections } =
|
const { state, clearState, updateState, resetCustomRuleSelections, setMethodSectionsPinCommitted } =
|
||||||
useCreateFlow();
|
useCreateFlow();
|
||||||
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
||||||
useCreateFlowDraftSaveBanner();
|
useCreateFlowDraftSaveBanner();
|
||||||
@@ -253,13 +256,19 @@ function CreateFlowLayoutContent({
|
|||||||
if (titleOk && editingId === last.id && sectionsClear) {
|
if (titleOk && editingId === last.id && sectionsClear) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateState(createFlowStateFromPublishedRule(last));
|
updateState({
|
||||||
|
...createFlowStateFromPublishedRule(last),
|
||||||
|
/** Keep UI-only facet pin flags across published re-hydration (wizard draft field; not stored on publish). */
|
||||||
|
methodSectionsPinCommitted: state.methodSectionsPinCommitted,
|
||||||
|
});
|
||||||
}, [
|
}, [
|
||||||
currentStep,
|
currentStep,
|
||||||
router,
|
router,
|
||||||
updateState,
|
updateState,
|
||||||
state.editingPublishedRuleId,
|
state.editingPublishedRuleId,
|
||||||
state.title,
|
state.title,
|
||||||
|
state.methodSectionsPinCommitted,
|
||||||
|
state.sections?.length,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
|
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
|
||||||
@@ -348,6 +357,13 @@ function CreateFlowLayoutContent({
|
|||||||
currentStep != null
|
currentStep != null
|
||||||
? CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP.get(currentStep)
|
? CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP.get(currentStep)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
/** Method-card steps tolerate `reviewReturn={edit-rule}` when `edit-rule ∉ FLOW_STEP_ORDER` makes `nextStep` null. Core values stay gated on linear `nextStep`. */
|
||||||
|
const showCustomRuleFooterConfirm =
|
||||||
|
Boolean(customRuleConfirmFooter) &&
|
||||||
|
(nextStep != null ||
|
||||||
|
(reviewReturnTarget != null &&
|
||||||
|
methodCardFacetSectionForConfirmStep(customRuleConfirmFooter.step) !=
|
||||||
|
undefined));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top banner stack rendered above the main column when any of the
|
* Top banner stack rendered above the main column when any of the
|
||||||
@@ -590,7 +606,8 @@ function CreateFlowLayoutContent({
|
|||||||
{footer.createFromTemplate}
|
{footer.createFromTemplate}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : customRuleConfirmFooter && nextStep ? (
|
) : showCustomRuleFooterConfirm &&
|
||||||
|
customRuleConfirmFooter ? (
|
||||||
<Button
|
<Button
|
||||||
buttonType="filled"
|
buttonType="filled"
|
||||||
palette="default"
|
palette="default"
|
||||||
@@ -601,6 +618,24 @@ function CreateFlowLayoutContent({
|
|||||||
}
|
}
|
||||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const cf = customRuleConfirmFooter;
|
||||||
|
const facet = methodCardFacetSectionForConfirmStep(cf.step);
|
||||||
|
if (facet != null && cf.selectionIds(state).length > 0) {
|
||||||
|
setMethodSectionsPinCommitted(facet, true);
|
||||||
|
}
|
||||||
|
if (reviewReturnTarget) {
|
||||||
|
const params = new URLSearchParams(
|
||||||
|
searchParams?.toString() ?? "",
|
||||||
|
);
|
||||||
|
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
|
||||||
|
const qs = params.toString();
|
||||||
|
router.push(
|
||||||
|
qs.length > 0
|
||||||
|
? `/create/${reviewReturnTarget}?${qs}`
|
||||||
|
: `/create/${reviewReturnTarget}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
goToNextStep();
|
goToNextStep();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -638,6 +673,19 @@ function CreateFlowLayoutContent({
|
|||||||
? "/create/review"
|
? "/create/review"
|
||||||
: "/",
|
: "/",
|
||||||
)
|
)
|
||||||
|
: reviewReturnTarget
|
||||||
|
? () => {
|
||||||
|
const params = new URLSearchParams(
|
||||||
|
searchParams?.toString() ?? "",
|
||||||
|
);
|
||||||
|
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
|
||||||
|
const qs = params.toString();
|
||||||
|
router.push(
|
||||||
|
qs.length > 0
|
||||||
|
? `/create/${reviewReturnTarget}?${qs}`
|
||||||
|
: `/create/${reviewReturnTarget}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
: previousStep
|
: previousStep
|
||||||
? goToPreviousStep
|
? goToPreviousStep
|
||||||
: undefined
|
: undefined
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ export function SignedInDraftHydration({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const urlStep = parseCreateFlowScreenFromPathname(pathname ?? null);
|
||||||
|
/** Owner “view published rule” shell — never merge server draft or redirect to `currentStep`. */
|
||||||
|
if (urlStep === "completed") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoadingHydration(true);
|
setLoadingHydration(true);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type {
|
import type {
|
||||||
|
CreateFlowMethodCardFacetSection,
|
||||||
CreateFlowState,
|
CreateFlowState,
|
||||||
CreateFlowContextValue,
|
CreateFlowContextValue,
|
||||||
CreateFlowStep,
|
CreateFlowStep,
|
||||||
@@ -137,6 +138,19 @@ export function CreateFlowProvider({
|
|||||||
setInteractionTouched(true);
|
setInteractionTouched(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setMethodSectionsPinCommitted = useCallback(
|
||||||
|
(section: CreateFlowMethodCardFacetSection, committed: boolean) => {
|
||||||
|
setState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
methodSectionsPinCommitted: {
|
||||||
|
...(prevState.methodSectionsPinCommitted ?? {}),
|
||||||
|
[section]: committed,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const updateState = useCallback((updates: Partial<CreateFlowState>) => {
|
const updateState = useCallback((updates: Partial<CreateFlowState>) => {
|
||||||
setState((prevState) => {
|
setState((prevState) => {
|
||||||
const merged: CreateFlowState = { ...prevState, ...updates };
|
const merged: CreateFlowState = { ...prevState, ...updates };
|
||||||
@@ -179,6 +193,7 @@ export function CreateFlowProvider({
|
|||||||
selectedMembershipMethodIds: _e,
|
selectedMembershipMethodIds: _e,
|
||||||
selectedDecisionApproachIds: _f,
|
selectedDecisionApproachIds: _f,
|
||||||
selectedConflictManagementIds: _g,
|
selectedConflictManagementIds: _g,
|
||||||
|
methodSectionsPinCommitted: _h,
|
||||||
...rest
|
...rest
|
||||||
} = prev;
|
} = prev;
|
||||||
return rest;
|
return rest;
|
||||||
@@ -195,6 +210,7 @@ export function CreateFlowProvider({
|
|||||||
replaceState,
|
replaceState,
|
||||||
clearState,
|
clearState,
|
||||||
resetCustomRuleSelections,
|
resetCustomRuleSelections,
|
||||||
|
setMethodSectionsPinCommitted,
|
||||||
interactionTouched,
|
interactionTouched,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useCreateFlow } from "../context/CreateFlowContext";
|
|||||||
import type { CreateFlowStep } from "../types";
|
import type { CreateFlowStep } from "../types";
|
||||||
import {
|
import {
|
||||||
type CreateFlowNavigationOptions,
|
type CreateFlowNavigationOptions,
|
||||||
|
type CreateFlowReviewReturnTarget,
|
||||||
|
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||||
buildTemplateReviewHref,
|
buildTemplateReviewHref,
|
||||||
getNextStep,
|
getNextStep,
|
||||||
getPreviousStep,
|
getPreviousStep,
|
||||||
@@ -46,7 +48,10 @@ export function useCreateFlowNavigation(
|
|||||||
currentStep: CreateFlowStep | null;
|
currentStep: CreateFlowStep | null;
|
||||||
goToNextStep: () => void;
|
goToNextStep: () => void;
|
||||||
goToPreviousStep: () => void;
|
goToPreviousStep: () => void;
|
||||||
goToStep: (_step: CreateFlowStep) => void;
|
goToStep: (
|
||||||
|
_step: CreateFlowStep,
|
||||||
|
_navOpts?: { reviewReturn?: CreateFlowReviewReturnTarget },
|
||||||
|
) => void;
|
||||||
canGoNext: () => boolean;
|
canGoNext: () => boolean;
|
||||||
canGoBack: () => boolean;
|
canGoBack: () => boolean;
|
||||||
nextStep: CreateFlowStep | null;
|
nextStep: CreateFlowStep | null;
|
||||||
@@ -122,11 +127,21 @@ export function useCreateFlowNavigation(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const goToStep = useCallback(
|
const goToStep = useCallback(
|
||||||
(step: CreateFlowStep) => {
|
(
|
||||||
|
step: CreateFlowStep,
|
||||||
|
navOpts?: { reviewReturn?: CreateFlowReviewReturnTarget },
|
||||||
|
) => {
|
||||||
blurActiveElement();
|
blurActiveElement();
|
||||||
router.push(`/create/${step}`);
|
const params = new URLSearchParams(searchParams?.toString() ?? "");
|
||||||
|
if (navOpts?.reviewReturn != null) {
|
||||||
|
params.set(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, navOpts.reviewReturn);
|
||||||
|
} else {
|
||||||
|
params.delete(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
router.push(qs.length > 0 ? `/create/${step}?${qs}` : `/create/${step}`);
|
||||||
},
|
},
|
||||||
[router],
|
[router, searchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const canGoNext = useCallback(() => nextStep !== null, [nextStep]);
|
const canGoNext = useCallback(() => nextStep !== null, [nextStep]);
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||||
|
import type { CreateFlowMethodCardFacetSection } from "../types";
|
||||||
|
import {
|
||||||
|
mergeCompactCardIdsWithPinnedSelected,
|
||||||
|
orderRankedMethodsWithPinnedSelection,
|
||||||
|
} from "../../../../lib/create/methodCardDisplayOrder";
|
||||||
|
import {
|
||||||
|
deriveCompactCards,
|
||||||
|
rankMethodsByScore,
|
||||||
|
useFacetRecommendations,
|
||||||
|
type RecommendationSection,
|
||||||
|
} from "./useFacetRecommendations";
|
||||||
|
|
||||||
|
type MethodEntry = { id: string; label: string; supportText: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies score ranking, compact-slot rules, optional “pinned selection” showcase
|
||||||
|
* order, and clears the pin draft flag when a section loses all selections.
|
||||||
|
*/
|
||||||
|
export function useMethodCardDeckOrdering(
|
||||||
|
section: RecommendationSection,
|
||||||
|
methods: readonly MethodEntry[],
|
||||||
|
selectedIds: readonly string[],
|
||||||
|
) {
|
||||||
|
const { state, setMethodSectionsPinCommitted } = useCreateFlow();
|
||||||
|
const facetKey = section as CreateFlowMethodCardFacetSection;
|
||||||
|
const { scoresBySlug, hasAnyFacets } = useFacetRecommendations(section);
|
||||||
|
|
||||||
|
const pinStored =
|
||||||
|
state.methodSectionsPinCommitted?.[facetKey] === true;
|
||||||
|
const pinActive = Boolean(pinStored && selectedIds.length > 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIds.length > 0) return;
|
||||||
|
if (!pinStored) return;
|
||||||
|
setMethodSectionsPinCommitted(facetKey, false);
|
||||||
|
}, [
|
||||||
|
facetKey,
|
||||||
|
pinStored,
|
||||||
|
selectedIds.length,
|
||||||
|
setMethodSectionsPinCommitted,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rankedMethods = useMemo(
|
||||||
|
() => rankMethodsByScore(methods, scoresBySlug),
|
||||||
|
[methods, scoresBySlug],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayMethods = useMemo(
|
||||||
|
() =>
|
||||||
|
orderRankedMethodsWithPinnedSelection(
|
||||||
|
rankedMethods,
|
||||||
|
selectedIds,
|
||||||
|
pinActive,
|
||||||
|
),
|
||||||
|
[rankedMethods, selectedIds, pinActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { compactCardIds: baseCompactCardIds, recommendedIds } = useMemo(
|
||||||
|
() =>
|
||||||
|
deriveCompactCards(
|
||||||
|
rankedMethods,
|
||||||
|
scoresBySlug,
|
||||||
|
hasAnyFacets,
|
||||||
|
/* limit */ 5,
|
||||||
|
),
|
||||||
|
[rankedMethods, scoresBySlug, hasAnyFacets],
|
||||||
|
);
|
||||||
|
|
||||||
|
const compactCardIds = useMemo(
|
||||||
|
() =>
|
||||||
|
mergeCompactCardIdsWithPinnedSelected(
|
||||||
|
displayMethods.map((m) => m.id),
|
||||||
|
baseCompactCardIds,
|
||||||
|
selectedIds,
|
||||||
|
pinActive,
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
[displayMethods, baseCompactCardIds, selectedIds, pinActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sampleCards = useMemo(
|
||||||
|
() =>
|
||||||
|
displayMethods.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
label: entry.label,
|
||||||
|
supportText: entry.supportText,
|
||||||
|
recommended: recommendedIds.has(entry.id),
|
||||||
|
})),
|
||||||
|
[displayMethods, recommendedIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const methodById = useMemo(
|
||||||
|
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
|
||||||
|
[rankedMethods],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rankedMethods,
|
||||||
|
displayMethods,
|
||||||
|
compactCardIds,
|
||||||
|
recommendedIds,
|
||||||
|
sampleCards,
|
||||||
|
methodById,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,15 +14,11 @@
|
|||||||
* the chip selection and any user edits as a `communicationMethodDetailsById[id]` override.
|
* the chip selection and any user edits as a `communicationMethodDetailsById[id]` override.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
import {
|
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||||
deriveCompactCards,
|
|
||||||
rankMethodsByScore,
|
|
||||||
useFacetRecommendations,
|
|
||||||
} from "../../hooks/useFacetRecommendations";
|
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import CardStack from "../../../../components/cards/CardStack";
|
import CardStack from "../../../../components/cards/CardStack";
|
||||||
import Create from "../../../../components/modals/Create";
|
import Create from "../../../../components/modals/Create";
|
||||||
@@ -49,32 +45,10 @@ export function CommunicationMethodsScreen() {
|
|||||||
|
|
||||||
const selectedIds = state.selectedCommunicationMethodIds ?? [];
|
const selectedIds = state.selectedCommunicationMethodIds ?? [];
|
||||||
|
|
||||||
const { scoresBySlug, hasAnyFacets } =
|
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
||||||
useFacetRecommendations("communication");
|
"communication",
|
||||||
const rankedMethods = useMemo(
|
comm.methods,
|
||||||
() => rankMethodsByScore(comm.methods, scoresBySlug),
|
selectedIds,
|
||||||
[comm.methods, scoresBySlug],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { compactCardIds, recommendedIds } = useMemo(
|
|
||||||
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
|
|
||||||
[rankedMethods, scoresBySlug, hasAnyFacets],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sampleCards = useMemo(
|
|
||||||
() =>
|
|
||||||
rankedMethods.map((entry) => ({
|
|
||||||
id: entry.id,
|
|
||||||
label: entry.label,
|
|
||||||
supportText: entry.supportText,
|
|
||||||
recommended: recommendedIds.has(entry.id),
|
|
||||||
})),
|
|
||||||
[rankedMethods, recommendedIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const methodById = useMemo(
|
|
||||||
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
|
|
||||||
[rankedMethods],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
|
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
|
||||||
|
|||||||
@@ -12,15 +12,11 @@
|
|||||||
* any user edits as a `conflictManagementDetailsById[id]` override.
|
* any user edits as a `conflictManagementDetailsById[id]` override.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
import {
|
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||||
deriveCompactCards,
|
|
||||||
rankMethodsByScore,
|
|
||||||
useFacetRecommendations,
|
|
||||||
} from "../../hooks/useFacetRecommendations";
|
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import CardStack from "../../../../components/cards/CardStack";
|
import CardStack from "../../../../components/cards/CardStack";
|
||||||
import Create from "../../../../components/modals/Create";
|
import Create from "../../../../components/modals/Create";
|
||||||
@@ -47,32 +43,10 @@ export function ConflictManagementScreen() {
|
|||||||
|
|
||||||
const selectedIds = state.selectedConflictManagementIds ?? [];
|
const selectedIds = state.selectedConflictManagementIds ?? [];
|
||||||
|
|
||||||
const { scoresBySlug, hasAnyFacets } =
|
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
||||||
useFacetRecommendations("conflictManagement");
|
"conflictManagement",
|
||||||
const rankedMethods = useMemo(
|
cm.methods,
|
||||||
() => rankMethodsByScore(cm.methods, scoresBySlug),
|
selectedIds,
|
||||||
[cm.methods, scoresBySlug],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { compactCardIds, recommendedIds } = useMemo(
|
|
||||||
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
|
|
||||||
[rankedMethods, scoresBySlug, hasAnyFacets],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sampleCards = useMemo(
|
|
||||||
() =>
|
|
||||||
rankedMethods.map((entry) => ({
|
|
||||||
id: entry.id,
|
|
||||||
label: entry.label,
|
|
||||||
supportText: entry.supportText,
|
|
||||||
recommended: recommendedIds.has(entry.id),
|
|
||||||
})),
|
|
||||||
[rankedMethods, recommendedIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const methodById = useMemo(
|
|
||||||
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
|
|
||||||
[rankedMethods],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
|
const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
|
||||||
|
|||||||
@@ -13,15 +13,11 @@
|
|||||||
* DB-driven content.
|
* DB-driven content.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
import {
|
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||||
deriveCompactCards,
|
|
||||||
rankMethodsByScore,
|
|
||||||
useFacetRecommendations,
|
|
||||||
} from "../../hooks/useFacetRecommendations";
|
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import CardStack from "../../../../components/cards/CardStack";
|
import CardStack from "../../../../components/cards/CardStack";
|
||||||
import Create from "../../../../components/modals/Create";
|
import Create from "../../../../components/modals/Create";
|
||||||
@@ -48,32 +44,10 @@ export function MembershipMethodsScreen() {
|
|||||||
|
|
||||||
const selectedIds = state.selectedMembershipMethodIds ?? [];
|
const selectedIds = state.selectedMembershipMethodIds ?? [];
|
||||||
|
|
||||||
const { scoresBySlug, hasAnyFacets } =
|
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
||||||
useFacetRecommendations("membership");
|
"membership",
|
||||||
const rankedMethods = useMemo(
|
mem.methods,
|
||||||
() => rankMethodsByScore(mem.methods, scoresBySlug),
|
selectedIds,
|
||||||
[mem.methods, scoresBySlug],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { compactCardIds, recommendedIds } = useMemo(
|
|
||||||
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
|
|
||||||
[rankedMethods, scoresBySlug, hasAnyFacets],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sampleCards = useMemo(
|
|
||||||
() =>
|
|
||||||
rankedMethods.map((entry) => ({
|
|
||||||
id: entry.id,
|
|
||||||
label: entry.label,
|
|
||||||
supportText: entry.supportText,
|
|
||||||
recommended: recommendedIds.has(entry.id),
|
|
||||||
})),
|
|
||||||
[rankedMethods, recommendedIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const methodById = useMemo(
|
|
||||||
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
|
|
||||||
[rankedMethods],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
|
const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
|
||||||
|
|||||||
@@ -15,18 +15,31 @@ import {
|
|||||||
type FinalReviewCategoryRowDetailed,
|
type FinalReviewCategoryRowDetailed,
|
||||||
} from "../../../../../lib/create/buildFinalReviewCategories";
|
} from "../../../../../lib/create/buildFinalReviewCategories";
|
||||||
import { applyFinalReviewChipEditPatch } from "../../../../../lib/create/applyFinalReviewChipEditPatch";
|
import { applyFinalReviewChipEditPatch } from "../../../../../lib/create/applyFinalReviewChipEditPatch";
|
||||||
import type { TemplateChipDetail } from "../../../../../lib/create/templateReviewMapping";
|
import type {
|
||||||
|
TemplateChipDetail,
|
||||||
|
TemplateFacetGroupKey,
|
||||||
|
} from "../../../../../lib/create/templateReviewMapping";
|
||||||
import {
|
import {
|
||||||
FinalReviewChipEditModal,
|
FinalReviewChipEditModal,
|
||||||
type FinalReviewChipEditPatch,
|
type FinalReviewChipEditPatch,
|
||||||
type FinalReviewChipEditTarget,
|
type FinalReviewChipEditTarget,
|
||||||
} from "../../components/FinalReviewChipEditModal";
|
} from "../../components/FinalReviewChipEditModal";
|
||||||
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
|
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
|
||||||
|
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
|
||||||
|
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
|
||||||
import {
|
import {
|
||||||
getAssetPath,
|
getAssetPath,
|
||||||
vectorMarkPath,
|
vectorMarkPath,
|
||||||
} from "../../../../../lib/assetUtils";
|
} from "../../../../../lib/assetUtils";
|
||||||
|
|
||||||
|
const FACET_FALLBACK_ORDER: readonly TemplateFacetGroupKey[] = [
|
||||||
|
"coreValues",
|
||||||
|
"communication",
|
||||||
|
"membership",
|
||||||
|
"decisionApproaches",
|
||||||
|
"conflictManagement",
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `finalReview.json.categories` ships a demo ordering + localized names
|
* `finalReview.json.categories` ships a demo ordering + localized names
|
||||||
* (Values / Communication / Membership / Decision-making / Conflict
|
* (Values / Communication / Membership / Decision-making / Conflict
|
||||||
@@ -75,6 +88,7 @@ export function FinalReviewScreen({
|
|||||||
variant?: "default" | "editPublished";
|
variant?: "default" | "editPublished";
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||||
|
const { goToStep } = useCreateFlowNavigation();
|
||||||
const mdUp = useCreateFlowMdUp();
|
const mdUp = useCreateFlowMdUp();
|
||||||
const t = useTranslation("create.reviewAndComplete.finalReview");
|
const t = useTranslation("create.reviewAndComplete.finalReview");
|
||||||
const m = useMessages();
|
const m = useMessages();
|
||||||
@@ -116,13 +130,23 @@ export function FinalReviewScreen({
|
|||||||
const derived = buildFinalReviewCategoryRowsDetailed(state, names);
|
const derived = buildFinalReviewCategoryRowsDetailed(state, names);
|
||||||
const rowsToRender: readonly FinalReviewCategoryRowDetailed[] =
|
const rowsToRender: readonly FinalReviewCategoryRowDetailed[] =
|
||||||
derived.length > 0 ? derived : fallbackRows;
|
derived.length > 0 ? derived : fallbackRows;
|
||||||
|
const usingFallbackRows = derived.length === 0;
|
||||||
|
|
||||||
const lookup = new Map<
|
const lookup = new Map<
|
||||||
string,
|
string,
|
||||||
{ target: FinalReviewChipEditTarget | null; readOnly: TemplateChipDetail }
|
{ target: FinalReviewChipEditTarget | null; readOnly: TemplateChipDetail }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
const cats: Category[] = rowsToRender.map((row) => {
|
const cats: Category[] = rowsToRender.map((row, rowIndex) => {
|
||||||
|
const effectiveGroupKey: TemplateFacetGroupKey | null =
|
||||||
|
row.groupKey ??
|
||||||
|
(usingFallbackRows && rowIndex < FACET_FALLBACK_ORDER.length
|
||||||
|
? FACET_FALLBACK_ORDER[rowIndex]
|
||||||
|
: null);
|
||||||
|
|
||||||
|
const reviewReturn =
|
||||||
|
variant === "editPublished" ? ("edit-rule" as const) : ("final-review" as const);
|
||||||
|
|
||||||
const chipOptions = row.entries.map((entry, idx) => {
|
const chipOptions = row.entries.map((entry, idx) => {
|
||||||
const chipId = `${row.name}-${idx}`;
|
const chipId = `${row.name}-${idx}`;
|
||||||
const readOnly: TemplateChipDetail = {
|
const readOnly: TemplateChipDetail = {
|
||||||
@@ -150,6 +174,7 @@ export function FinalReviewScreen({
|
|||||||
return {
|
return {
|
||||||
name: row.name,
|
name: row.name,
|
||||||
chipOptions,
|
chipOptions,
|
||||||
|
addButton: effectiveGroupKey != null,
|
||||||
onChipClick: (_categoryName: string, chipId: string) => {
|
onChipClick: (_categoryName: string, chipId: string) => {
|
||||||
const hit = lookup.get(chipId);
|
const hit = lookup.get(chipId);
|
||||||
if (!hit) return;
|
if (!hit) return;
|
||||||
@@ -160,6 +185,15 @@ export function FinalReviewScreen({
|
|||||||
setActiveReadOnlyDetail(hit.readOnly);
|
setActiveReadOnlyDetail(hit.readOnly);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onAddClick:
|
||||||
|
effectiveGroupKey != null
|
||||||
|
? () => {
|
||||||
|
markCreateFlowInteraction();
|
||||||
|
goToStep(createFlowStepForFacetGroup(effectiveGroupKey), {
|
||||||
|
reviewReturn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return { categories: cats, chipLookup: lookup };
|
return { categories: cats, chipLookup: lookup };
|
||||||
@@ -167,6 +201,8 @@ export function FinalReviewScreen({
|
|||||||
m.create.reviewAndComplete.finalReview.categories,
|
m.create.reviewAndComplete.finalReview.categories,
|
||||||
state,
|
state,
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
|
goToStep,
|
||||||
|
variant,
|
||||||
]);
|
]);
|
||||||
void chipLookup;
|
void chipLookup;
|
||||||
|
|
||||||
|
|||||||
@@ -23,15 +23,10 @@ import Create from "../../../../components/modals/Create";
|
|||||||
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||||
import InfoMessageBox from "../../../../components/controls/InfoMessageBox";
|
import InfoMessageBox from "../../../../components/controls/InfoMessageBox";
|
||||||
import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMessageBox/InfoMessageBox.types";
|
import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMessageBox/InfoMessageBox.types";
|
||||||
import type { CardStackItem } from "../../../../components/cards/CardStack/CardStack.types";
|
|
||||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
import {
|
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||||
deriveCompactCards,
|
|
||||||
rankMethodsByScore,
|
|
||||||
useFacetRecommendations,
|
|
||||||
} from "../../hooks/useFacetRecommendations";
|
|
||||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||||
import { DecisionApproachEditFields } from "../../components/methodEditFields";
|
import { DecisionApproachEditFields } from "../../components/methodEditFields";
|
||||||
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||||
@@ -62,32 +57,10 @@ export function DecisionApproachesScreen() {
|
|||||||
[da.messageBox.items],
|
[da.messageBox.items],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { scoresBySlug, hasAnyFacets } =
|
const { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
||||||
useFacetRecommendations("decisionApproaches");
|
"decisionApproaches",
|
||||||
const rankedMethods = useMemo(
|
da.methods,
|
||||||
() => rankMethodsByScore(da.methods, scoresBySlug),
|
selectedIds,
|
||||||
[da.methods, scoresBySlug],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { compactCardIds, recommendedIds } = useMemo(
|
|
||||||
() => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
|
|
||||||
[rankedMethods, scoresBySlug, hasAnyFacets],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sampleCards: CardStackItem[] = useMemo(
|
|
||||||
() =>
|
|
||||||
rankedMethods.map((entry) => ({
|
|
||||||
id: entry.id,
|
|
||||||
label: entry.label,
|
|
||||||
supportText: entry.supportText,
|
|
||||||
recommended: recommendedIds.has(entry.id),
|
|
||||||
})),
|
|
||||||
[rankedMethods, recommendedIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const methodById = useMemo(
|
|
||||||
() => new Map(rankedMethods.map((entry) => [entry.id, entry])),
|
|
||||||
[rankedMethods],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const sidebarDescription = (
|
const sidebarDescription = (
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export type CreateFlowTextStateField =
|
|||||||
| "communityContext"
|
| "communityContext"
|
||||||
| "communitySaveEmail";
|
| "communitySaveEmail";
|
||||||
|
|
||||||
|
/** Facet-backed method card stacks (`GET /api/create-flow/methods?section=`). */
|
||||||
|
export type CreateFlowMethodCardFacetSection =
|
||||||
|
| "communication"
|
||||||
|
| "membership"
|
||||||
|
| "decisionApproaches"
|
||||||
|
| "conflictManagement";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialized chip row for `community-structure` (preset + custom labels).
|
* Serialized chip row for `community-structure` (preset + custom labels).
|
||||||
* Stored in drafts so custom chips survive refresh and server sync.
|
* Stored in drafts so custom chips survive refresh and server sync.
|
||||||
@@ -130,6 +137,14 @@ export interface CreateFlowState {
|
|||||||
selectedDecisionApproachIds?: string[];
|
selectedDecisionApproachIds?: string[];
|
||||||
/** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.customRule.conflictManagement` presets. */
|
/** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.customRule.conflictManagement` presets. */
|
||||||
selectedConflictManagementIds?: string[];
|
selectedConflictManagementIds?: string[];
|
||||||
|
/**
|
||||||
|
* After **Confirm** on a method card step (`communication-methods`, etc.)
|
||||||
|
* with ≥1 selection, reorder UI with selected cards first until the pin is
|
||||||
|
* cleared by an empty selection (or resetting custom-rule state).
|
||||||
|
*/
|
||||||
|
methodSectionsPinCommitted?: Partial<
|
||||||
|
Record<CreateFlowMethodCardFacetSection, boolean>
|
||||||
|
>;
|
||||||
/**
|
/**
|
||||||
* User edits from the `final-review` edit modal, keyed by preset method id
|
* User edits from the `final-review` edit modal, keyed by preset method id
|
||||||
* (e.g. `"signal"`). Merged onto preset defaults at publish time so the
|
* (e.g. `"signal"`). Merged onto preset defaults at publish time so the
|
||||||
@@ -209,6 +224,14 @@ export interface CreateFlowContextValue {
|
|||||||
* after a prior "Customize template" prefill.
|
* after a prior "Customize template" prefill.
|
||||||
*/
|
*/
|
||||||
resetCustomRuleSelections: () => void;
|
resetCustomRuleSelections: () => void;
|
||||||
|
/**
|
||||||
|
* Mark whether a facet method stack should pin the author’s selections to
|
||||||
|
* the head of expanded + compact order (set from the footer Confirm).
|
||||||
|
*/
|
||||||
|
setMethodSectionsPinCommitted: (
|
||||||
|
section: CreateFlowMethodCardFacetSection,
|
||||||
|
committed: boolean,
|
||||||
|
) => void;
|
||||||
/**
|
/**
|
||||||
* True after the user has edited any control inside the wizard. Screens flip
|
* True after the user has edited any control inside the wizard. Screens flip
|
||||||
* it via {@link markCreateFlowInteraction} from their event handlers.
|
* it via {@link markCreateFlowInteraction} from their event handlers.
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { CreateFlowState, CreateFlowStep } from "../types";
|
import type {
|
||||||
|
CreateFlowMethodCardFacetSection,
|
||||||
|
CreateFlowState,
|
||||||
|
CreateFlowStep,
|
||||||
|
} from "../types";
|
||||||
import type footerMessages from "../../../../messages/en/create/footer.json";
|
import type footerMessages from "../../../../messages/en/create/footer.json";
|
||||||
|
|
||||||
type FooterMessageKey = keyof typeof footerMessages;
|
type FooterMessageKey = keyof typeof footerMessages;
|
||||||
@@ -67,3 +71,24 @@ export const CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP: ReadonlyMap<
|
|||||||
CreateFlowStep,
|
CreateFlowStep,
|
||||||
CustomRuleConfirmFooterStep
|
CustomRuleConfirmFooterStep
|
||||||
> = new Map(CUSTOM_RULE_CONFIRM_FOOTER_STEPS.map((e) => [e.step, e]));
|
> = new Map(CUSTOM_RULE_CONFIRM_FOOTER_STEPS.map((e) => [e.step, e]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a custom-rule Confirm footer step to the facet-backed method-card section
|
||||||
|
* (core values omit — chip MultiSelect uses a different ordering model).
|
||||||
|
*/
|
||||||
|
export function methodCardFacetSectionForConfirmStep(
|
||||||
|
step: CustomRuleConfirmFooterStep["step"],
|
||||||
|
): CreateFlowMethodCardFacetSection | undefined {
|
||||||
|
switch (step) {
|
||||||
|
case "communication-methods":
|
||||||
|
return "communication";
|
||||||
|
case "membership-methods":
|
||||||
|
return "membership";
|
||||||
|
case "decision-approaches":
|
||||||
|
return "decisionApproaches";
|
||||||
|
case "conflict-management":
|
||||||
|
return "conflictManagement";
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { TemplateFacetGroupKey } from "../../../../lib/create/templateReviewMapping";
|
||||||
|
import type { CreateFlowStep } from "../types";
|
||||||
|
|
||||||
|
const MAP: Record<TemplateFacetGroupKey, CreateFlowStep> = {
|
||||||
|
coreValues: "core-values",
|
||||||
|
communication: "communication-methods",
|
||||||
|
membership: "membership-methods",
|
||||||
|
decisionApproaches: "decision-approaches",
|
||||||
|
conflictManagement: "conflict-management",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom-rule URL segment for a final-review category row (`+` navigation).
|
||||||
|
*/
|
||||||
|
export function createFlowStepForFacetGroup(
|
||||||
|
groupKey: TemplateFacetGroupKey,
|
||||||
|
): CreateFlowStep {
|
||||||
|
return MAP[groupKey];
|
||||||
|
}
|
||||||
@@ -153,6 +153,20 @@ export function parseCreateFlowScreenFromPathname(
|
|||||||
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
|
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
|
||||||
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
|
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
|
||||||
|
|
||||||
|
/** `/create/{step}?reviewReturn=…` — set when opening a custom-rule step from final-review or edit-rule via + */
|
||||||
|
export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const;
|
||||||
|
|
||||||
|
export type CreateFlowReviewReturnTarget = "final-review" | "edit-rule";
|
||||||
|
|
||||||
|
export function parseReviewReturnSearchParam(
|
||||||
|
searchParams: { get: (name: string) => string | null } | null | undefined,
|
||||||
|
): CreateFlowReviewReturnTarget | null {
|
||||||
|
if (!searchParams) return null;
|
||||||
|
const raw = searchParams.get(CREATE_FLOW_REVIEW_RETURN_QUERY_KEY);
|
||||||
|
if (raw === "final-review" || raw === "edit-rule") return raw;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `/create/review-template/{slug}` with optional marker so chrome can send
|
* `/create/review-template/{slug}` with optional marker so chrome can send
|
||||||
* footer Back to `/create/review` instead of marketing home.
|
* footer Back to `/create/review` instead of marketing home.
|
||||||
|
|||||||
@@ -351,6 +351,11 @@ export function ProfilePageView({
|
|||||||
{
|
{
|
||||||
id: "view",
|
id: "view",
|
||||||
label: t("viewPublic"),
|
label: t("viewPublic"),
|
||||||
|
href: `/rules/${encodeURIComponent(rule.id)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manage",
|
||||||
|
label: t("manageRule"),
|
||||||
href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`,
|
href: `/create/completed?ruleId=${encodeURIComponent(rule.id)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ export const GET = apiRoute("rules.list", async (request: NextRequest) => {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
|
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
|
||||||
|
|
||||||
|
/** Public catalog: mirror profile “my rules” recency semantics (last touched first). */
|
||||||
const rules = await prisma.publishedRule.findMany({
|
const rules = await prisma.publishedRule.findMany({
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
||||||
take,
|
take,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const RuleContainer = memo<RuleProps>(
|
|||||||
hasBottomLinks = false,
|
hasBottomLinks = false,
|
||||||
bottomStatusLabel,
|
bottomStatusLabel,
|
||||||
bottomLinks,
|
bottomLinks,
|
||||||
|
recommended = false,
|
||||||
}) => {
|
}) => {
|
||||||
const size = sizeProp ?? "L";
|
const size = sizeProp ?? "L";
|
||||||
|
|
||||||
@@ -96,6 +97,7 @@ const RuleContainer = memo<RuleProps>(
|
|||||||
hasBottomLinks={hasBottomLinks}
|
hasBottomLinks={hasBottomLinks}
|
||||||
bottomStatusLabel={bottomStatusLabel}
|
bottomStatusLabel={bottomStatusLabel}
|
||||||
bottomLinks={bottomLinks}
|
bottomLinks={bottomLinks}
|
||||||
|
recommended={recommended}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { RuleSizeValue } from "../../../../lib/propNormalization";
|
|||||||
export interface Category {
|
export interface Category {
|
||||||
name: string;
|
name: string;
|
||||||
chipOptions: ChipOption[];
|
chipOptions: ChipOption[];
|
||||||
|
/** When `false`, hide the row’s + affordance. Default: show when the Rule allows adds. */
|
||||||
|
addButton?: boolean;
|
||||||
onChipClick?: (categoryName: string, chipId: string) => void;
|
onChipClick?: (categoryName: string, chipId: string) => void;
|
||||||
onAddClick?: (categoryName: string) => void;
|
onAddClick?: (categoryName: string) => void;
|
||||||
onCustomChipConfirm?: (
|
onCustomChipConfirm?: (
|
||||||
@@ -58,6 +60,12 @@ export interface RuleProps {
|
|||||||
/** Uppercase chip (e.g. IN PROGRESS); omit when no left badge. */
|
/** Uppercase chip (e.g. IN PROGRESS); omit when no left badge. */
|
||||||
bottomStatusLabel?: string;
|
bottomStatusLabel?: string;
|
||||||
bottomLinks?: RuleBottomLink[];
|
bottomLinks?: RuleBottomLink[];
|
||||||
|
/**
|
||||||
|
* When set and the card is collapsed (`expanded` false), show the
|
||||||
|
* “RECOMMENDED” tag above the title (e.g. templates index). Ignored when
|
||||||
|
* `expanded` — Figma `22142:898446` compact `Card / Rule` only.
|
||||||
|
*/
|
||||||
|
recommended?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuleViewProps {
|
export interface RuleViewProps {
|
||||||
@@ -81,4 +89,5 @@ export interface RuleViewProps {
|
|||||||
hasBottomLinks?: boolean;
|
hasBottomLinks?: boolean;
|
||||||
bottomStatusLabel?: string;
|
bottomStatusLabel?: string;
|
||||||
bottomLinks?: RuleBottomLink[];
|
bottomLinks?: RuleBottomLink[];
|
||||||
|
recommended?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslation } from "../../../contexts/MessagesContext";
|
|||||||
import MultiSelect from "../../controls/MultiSelect";
|
import MultiSelect from "../../controls/MultiSelect";
|
||||||
import InlineTextButton from "../../buttons/InlineTextButton";
|
import InlineTextButton from "../../buttons/InlineTextButton";
|
||||||
import NavigationLink from "../../navigation/Link";
|
import NavigationLink from "../../navigation/Link";
|
||||||
|
import Tag from "../../utility/Tag";
|
||||||
import type { RuleBottomLink, RuleViewProps } from "./Rule.types";
|
import type { RuleBottomLink, RuleViewProps } from "./Rule.types";
|
||||||
|
|
||||||
export function RuleView({
|
export function RuleView({
|
||||||
@@ -28,6 +29,7 @@ export function RuleView({
|
|||||||
hasBottomLinks = false,
|
hasBottomLinks = false,
|
||||||
bottomStatusLabel,
|
bottomStatusLabel,
|
||||||
bottomLinks,
|
bottomLinks,
|
||||||
|
recommended = false,
|
||||||
}: RuleViewProps) {
|
}: RuleViewProps) {
|
||||||
const t = useTranslation("ruleCard");
|
const t = useTranslation("ruleCard");
|
||||||
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
|
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
|
||||||
@@ -90,6 +92,7 @@ export function RuleView({
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// Title typography - use CSS responsive classes
|
// Title typography - use CSS responsive classes
|
||||||
|
const showRecommendedTag = recommended && !expanded;
|
||||||
const titleClass = `
|
const titleClass = `
|
||||||
max-[639px]:font-inter max-[639px]:font-bold max-[639px]:text-[20px] max-[639px]:leading-[28px]
|
max-[639px]:font-inter max-[639px]:font-bold max-[639px]:text-[20px] max-[639px]:leading-[28px]
|
||||||
min-[640px]:max-[1023px]:font-bricolage-grotesque min-[640px]:max-[1023px]:font-bold min-[640px]:max-[1023px]:text-[28px] min-[640px]:max-[1023px]:leading-[36px]
|
min-[640px]:max-[1023px]:font-bricolage-grotesque min-[640px]:max-[1023px]:font-bold min-[640px]:max-[1023px]:text-[28px] min-[640px]:max-[1023px]:leading-[36px]
|
||||||
@@ -256,12 +259,22 @@ export function RuleView({
|
|||||||
{/* Inner container for header text with padding */}
|
{/* Inner container for header text with padding */}
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
flex items-center justify-center w-full
|
flex w-full
|
||||||
|
${
|
||||||
|
showRecommendedTag
|
||||||
|
? "flex-col items-start justify-center gap-1"
|
||||||
|
: "items-center justify-center"
|
||||||
|
}
|
||||||
max-[639px]:pl-[8px] max-[639px]:py-[8px]
|
max-[639px]:pl-[8px] max-[639px]:py-[8px]
|
||||||
min-[640px]:max-[1023px]:pl-[12px] min-[640px]:max-[1023px]:py-[12px]
|
min-[640px]:max-[1023px]:pl-[12px] min-[640px]:max-[1023px]:py-[12px]
|
||||||
min-[1024px]:px-[16px] min-[1024px]:py-[24px]
|
min-[1024px]:px-[16px] min-[1024px]:py-[24px]
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
{showRecommendedTag ? (
|
||||||
|
<Tag variant="templateRecommended">
|
||||||
|
{t("recommendedLabel")}
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
<h3
|
<h3
|
||||||
className={`${titleClass} cursor-inherit text-[var(--color-content-invert-primary)] overflow-hidden text-ellipsis w-full`}
|
className={`${titleClass} cursor-inherit text-[var(--color-content-invert-primary)] overflow-hidden text-ellipsis w-full`}
|
||||||
>
|
>
|
||||||
@@ -384,7 +397,9 @@ export function RuleView({
|
|||||||
onCustomChipClose={(chipId) => {
|
onCustomChipClose={(chipId) => {
|
||||||
category.onCustomChipClose?.(category.name, chipId);
|
category.onCustomChipClose?.(category.name, chipId);
|
||||||
}}
|
}}
|
||||||
addButton={!hideCategoryAddButton}
|
addButton={
|
||||||
|
!hideCategoryAddButton && category.addButton !== false
|
||||||
|
}
|
||||||
addButtonText="" // Empty text for icon-only circular button
|
addButtonText="" // Empty text for icon-only circular button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export function GovernanceTemplateGrid({
|
|||||||
key={card.slug}
|
key={card.slug}
|
||||||
title={card.title}
|
title={card.title}
|
||||||
description={card.description}
|
description={card.description}
|
||||||
|
recommended={card.recommended === true}
|
||||||
size={cardSize}
|
size={cardSize}
|
||||||
className={`
|
className={`
|
||||||
select-none
|
select-none
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { TagProps } from "./Tag.types";
|
|||||||
const DEFAULT_LABELS: Record<TagProps["variant"], string> = {
|
const DEFAULT_LABELS: Record<TagProps["variant"], string> = {
|
||||||
recommended: "RECOMMENDED",
|
recommended: "RECOMMENDED",
|
||||||
selected: "SELECTED",
|
selected: "SELECTED",
|
||||||
|
templateRecommended: "RECOMMENDED",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
export type TagVariant = "recommended" | "selected";
|
export type TagVariant = "recommended" | "selected" | "templateRecommended";
|
||||||
|
|
||||||
export interface TagProps {
|
export interface TagProps {
|
||||||
/** Visual variant: recommended (yellow) or selected (dark) */
|
/**
|
||||||
|
* Visual variant: recommended (yellow), selected (dark on light),
|
||||||
|
* or templateRecommended (dark pill on pastel `Card / Rule` — Figma
|
||||||
|
* `22142:898446`).
|
||||||
|
*/
|
||||||
variant: TagVariant;
|
variant: TagVariant;
|
||||||
/** Tag text. Defaults to "RECOMMENDED" or "SELECTED" when not provided. */
|
/** Tag text. Defaults to "RECOMMENDED" or "SELECTED" when not provided. */
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ import type { TagViewProps } from "./Tag.types";
|
|||||||
*/
|
*/
|
||||||
export function TagView({ variant, children, className }: TagViewProps) {
|
export function TagView({ variant, children, className }: TagViewProps) {
|
||||||
const isRecommended = variant === "recommended";
|
const isRecommended = variant === "recommended";
|
||||||
const bgClass = isRecommended
|
const isTemplateRecommended = variant === "templateRecommended";
|
||||||
|
const bgClass = isTemplateRecommended
|
||||||
|
? "bg-[var(--color-surface-default-tertiary)]"
|
||||||
|
: isRecommended
|
||||||
? "bg-[var(--color-surface-inverse-brand-accent)]"
|
? "bg-[var(--color-surface-inverse-brand-accent)]"
|
||||||
: "bg-[var(--color-gray-1000)]";
|
: "bg-[var(--color-gray-1000)]";
|
||||||
const textClass = isRecommended
|
const textClass = isTemplateRecommended
|
||||||
|
? "text-[var(--color-gray-000)]"
|
||||||
|
: isRecommended
|
||||||
? "text-[var(--color-content-inverse-brand-primary)]"
|
? "text-[var(--color-content-inverse-brand-primary)]"
|
||||||
: "text-[var(--color-gray-000)]";
|
: "text-[var(--color-gray-000)]";
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -292,8 +292,8 @@ export type MyPublishedRule = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists the signed-in user’s published rules (newest first). Returns `null` on
|
* Lists the signed-in user’s published rules (**last updated first**, stable by id).
|
||||||
* network failure or unauthenticated response.
|
* Returns `null` on network failure or unauthenticated response.
|
||||||
*/
|
*/
|
||||||
export async function fetchMyPublishedRules(): Promise<
|
export async function fetchMyPublishedRules(): Promise<
|
||||||
MyPublishedRule[] | null
|
MyPublishedRule[] | null
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Bridges final-review → completed without query strings, and re-opens a rule
|
* Bridges final-review → completed without query strings, and re-opens a rule
|
||||||
* from profile (`/create/completed?ruleId=…`) after GET /api/rules/[id].
|
* from profile (`/create/completed?ruleId=…`) after GET /api/rules/[id]. Profile
|
||||||
|
* "Manage" links here; "View" uses `/rules/[id]`.
|
||||||
*/
|
*/
|
||||||
export const CREATE_FLOW_LAST_PUBLISHED_KEY = "createFlow.lastPublished";
|
export const CREATE_FLOW_LAST_PUBLISHED_KEY = "createFlow.lastPublished";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Reorders facet-ranked method presets so explicitly confirmed selections pin
|
||||||
|
* to the top while the remainder keeps score-based ranking (recommended before
|
||||||
|
* default).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Selected ids first (selection array order); then tail in `ranked` order. */
|
||||||
|
export function orderRankedMethodsWithPinnedSelection<T extends { id: string }>(
|
||||||
|
rankedMethods: readonly T[],
|
||||||
|
selectedIds: readonly string[],
|
||||||
|
pinActive: boolean,
|
||||||
|
): T[] {
|
||||||
|
if (!pinActive || selectedIds.length === 0) {
|
||||||
|
return [...rankedMethods];
|
||||||
|
}
|
||||||
|
const byId = new Map(rankedMethods.map((m) => [m.id, m] as const));
|
||||||
|
const head: T[] = [];
|
||||||
|
const picked = new Set<string>();
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
const row = byId.get(id);
|
||||||
|
if (!row || picked.has(id)) continue;
|
||||||
|
picked.add(id);
|
||||||
|
head.push(row);
|
||||||
|
}
|
||||||
|
const tail = rankedMethods.filter((m) => !picked.has(m.id));
|
||||||
|
return [...head, ...tail];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer selected ids in compact slots (up to `limit`), then facet-derived
|
||||||
|
* `baseCompact.compactCardIds`, then remaining methods in showcase order so
|
||||||
|
* selected cards surface even when they are outside the unpinned facet top-N.
|
||||||
|
*/
|
||||||
|
export function mergeCompactCardIdsWithPinnedSelected(
|
||||||
|
showcaseOrderIds: readonly string[],
|
||||||
|
baseCompactCardIds: readonly string[],
|
||||||
|
selectedIds: readonly string[],
|
||||||
|
pinActive: boolean,
|
||||||
|
limit: number,
|
||||||
|
): string[] {
|
||||||
|
if (!pinActive || selectedIds.length === 0) {
|
||||||
|
return [...baseCompactCardIds].slice(0, limit);
|
||||||
|
}
|
||||||
|
const valid = new Set(showcaseOrderIds);
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
if (out.length >= limit) break;
|
||||||
|
if (!valid.has(id) || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
out.push(id);
|
||||||
|
}
|
||||||
|
for (const id of baseCompactCardIds) {
|
||||||
|
if (out.length >= limit) break;
|
||||||
|
if (!valid.has(id) || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
out.push(id);
|
||||||
|
}
|
||||||
|
for (const id of showcaseOrderIds) {
|
||||||
|
if (out.length >= limit) break;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
out.push(id);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -65,7 +65,8 @@ export type OwnerPublishedRuleListItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists published rules owned by the given user (alphabetical by title, then id).
|
* Lists published rules owned by the given user (**most recently updated first**,
|
||||||
|
* then stable `id` tie-break).
|
||||||
* Returns `null` when the database is not configured or the query throws.
|
* Returns `null` when the database is not configured or the query throws.
|
||||||
*/
|
*/
|
||||||
export async function listPublishedRulesForUser(
|
export async function listPublishedRulesForUser(
|
||||||
@@ -79,7 +80,7 @@ export async function listPublishedRulesForUser(
|
|||||||
try {
|
try {
|
||||||
return await prisma.publishedRule.findMany({
|
return await prisma.publishedRule.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: [{ title: "asc" }, { id: "asc" }],
|
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
||||||
take: clamped,
|
take: clamped,
|
||||||
select: PUBLISHED_RULE_OWNER_LIST_SELECT,
|
select: PUBLISHED_RULE_OWNER_LIST_SELECT,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -112,6 +112,15 @@ export const createFlowStateSchema = z
|
|||||||
conflictManagementDetailsById: z
|
conflictManagementDetailsById: z
|
||||||
.record(conflictManagementDetailEntrySchema)
|
.record(conflictManagementDetailEntrySchema)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
methodSectionsPinCommitted: z
|
||||||
|
.object({
|
||||||
|
communication: z.boolean().optional(),
|
||||||
|
membership: z.boolean().optional(),
|
||||||
|
decisionApproaches: z.boolean().optional(),
|
||||||
|
conflictManagement: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
pendingTemplateAction: z
|
pendingTemplateAction: z
|
||||||
.object({
|
.object({
|
||||||
slug: z.string().max(200),
|
slug: z.string().max(200),
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export type GovernanceTemplateCatalogEntry = {
|
|||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
/** Path under public/ for getAssetPath() — Figma Asset / Template Mark */
|
/** Path under public/ for getAssetPath() — Figma Asset / Template Mark */
|
||||||
iconPath: string;
|
iconPath: string;
|
||||||
|
/**
|
||||||
|
* When true, the templates grid shows the “RECOMMENDED” tag (facet-based
|
||||||
|
* scores will set this in `ruleTemplateToGridEntry` when wired; catalog
|
||||||
|
* entries omit unless intentionally static).
|
||||||
|
*/
|
||||||
|
recommended?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** SVGs in `public/assets/template-mark/<slug>.svg` (kebab-case slug). */
|
/** SVGs in `public/assets/template-mark/<slug>.svg` (kebab-case slug). */
|
||||||
|
|||||||
@@ -11,16 +11,24 @@ import {
|
|||||||
export const TEMPLATE_GRID_FALLBACK_PRESENTATION = {
|
export const TEMPLATE_GRID_FALLBACK_PRESENTATION = {
|
||||||
iconPath: governanceTemplateIconPath("consensus"),
|
iconPath: governanceTemplateIconPath("consensus"),
|
||||||
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
|
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
|
||||||
|
recommended: false,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TemplateGridCardEntry = GovernanceTemplateCatalogEntry;
|
export type TemplateGridCardEntry = GovernanceTemplateCatalogEntry;
|
||||||
|
|
||||||
function presentationForSlug(slug: string): Pick<
|
function presentationForSlug(slug: string): Pick<
|
||||||
GovernanceTemplateCatalogEntry,
|
GovernanceTemplateCatalogEntry,
|
||||||
"iconPath" | "backgroundColor"
|
"iconPath" | "backgroundColor" | "recommended"
|
||||||
> {
|
> {
|
||||||
const catalog = getGovernanceTemplateCatalogEntry(slug);
|
const catalog = getGovernanceTemplateCatalogEntry(slug);
|
||||||
return catalog ?? TEMPLATE_GRID_FALLBACK_PRESENTATION;
|
if (catalog) {
|
||||||
|
return {
|
||||||
|
iconPath: catalog.iconPath,
|
||||||
|
backgroundColor: catalog.backgroundColor,
|
||||||
|
recommended: catalog.recommended === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return TEMPLATE_GRID_FALLBACK_PRESENTATION;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,6 +43,7 @@ export function ruleTemplateToGridEntry(template: RuleTemplateDto): TemplateGrid
|
|||||||
description,
|
description,
|
||||||
iconPath: pres.iconPath,
|
iconPath: pres.iconPath,
|
||||||
backgroundColor: pres.backgroundColor,
|
backgroundColor: pres.backgroundColor,
|
||||||
|
recommended: pres.recommended,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"ariaLabel": "Learn more about {title} governance pattern"
|
"ariaLabel": "Learn more about {title} governance pattern",
|
||||||
|
"recommendedLabel": "RECOMMENDED"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"createTemplateDescription": "Browse templates and adapt one for your community.",
|
"createTemplateDescription": "Browse templates and adapt one for your community.",
|
||||||
"createTemplateCta": "Browse templates",
|
"createTemplateCta": "Browse templates",
|
||||||
"viewPublic": "View",
|
"viewPublic": "View",
|
||||||
|
"manageRule": "Manage",
|
||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
"deleteRule": "Delete",
|
"deleteRule": "Delete",
|
||||||
"deleteRuleConfirm": "Delete this published rule? This cannot be undone.",
|
"deleteRuleConfirm": "Delete this published rule? This cannot be undone.",
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export default {
|
|||||||
control: { type: "boolean" },
|
control: { type: "boolean" },
|
||||||
description: "Whether the card is in expanded state",
|
description: "Whether the card is in expanded state",
|
||||||
},
|
},
|
||||||
|
recommended: {
|
||||||
|
control: { type: "boolean" },
|
||||||
|
description:
|
||||||
|
"When true and collapsed, show RECOMMENDED tag above the title (templates grid)",
|
||||||
|
},
|
||||||
size: {
|
size: {
|
||||||
control: { type: "select" },
|
control: { type: "select" },
|
||||||
options: ["XS", "S", "M", "L", "xs", "s", "m", "l"],
|
options: ["XS", "S", "M", "L", "xs", "s", "m", "l"],
|
||||||
@@ -54,6 +59,7 @@ export const Default = {
|
|||||||
"Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council.",
|
"Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council.",
|
||||||
backgroundColor: "bg-[var(--color-surface-default-brand-lime)]",
|
backgroundColor: "bg-[var(--color-surface-default-brand-lime)]",
|
||||||
expanded: false,
|
expanded: false,
|
||||||
|
recommended: false,
|
||||||
size: "L",
|
size: "L",
|
||||||
icon: (
|
icon: (
|
||||||
<Image
|
<Image
|
||||||
@@ -67,6 +73,18 @@ export const Default = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Figma `22142:898446` — compact `Card / Rule` with template recommendation tag. */
|
||||||
|
export const CollapsedWithRecommended = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
title: "Do-ocracy",
|
||||||
|
description:
|
||||||
|
"Authority is granted to those doing the work. If you do the task, you decide how it gets done.",
|
||||||
|
backgroundColor: "bg-[var(--color-surface-invert-brand-royal)]",
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const Expanded = {
|
export const Expanded = {
|
||||||
args: {
|
args: {
|
||||||
title: "Mutual Aid Mondays",
|
title: "Mutual Aid Mondays",
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ export default {
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
"Small status tag with recommended (yellow) or selected (dark) variant. Default labels are RECOMMENDED and SELECTED; pass children for custom text.",
|
"Small status tag: recommended (yellow), selected (dark on light), templateRecommended (dark on pastel `Card / Rule`). Default labels are RECOMMENDED/SELECTED; pass children for custom text.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
variant: {
|
variant: {
|
||||||
control: { type: "select" },
|
control: { type: "select" },
|
||||||
options: ["recommended", "selected"],
|
options: ["recommended", "selected", "templateRecommended"],
|
||||||
description: "Visual variant",
|
description: "Visual variant",
|
||||||
},
|
},
|
||||||
children: {
|
children: {
|
||||||
@@ -37,6 +37,12 @@ export const Selected = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TemplateRecommended = {
|
||||||
|
args: {
|
||||||
|
variant: "templateRecommended",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const CustomLabel = {
|
export const CustomLabel = {
|
||||||
args: {
|
args: {
|
||||||
variant: "recommended",
|
variant: "recommended",
|
||||||
|
|||||||
@@ -12,12 +12,22 @@ import {
|
|||||||
* window globals. Keeps the test readable vs. threading refs everywhere.
|
* window globals. Keeps the test readable vs. threading refs everywhere.
|
||||||
*/
|
*/
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const { state, updateState, resetCustomRuleSelections } = useCreateFlow();
|
const {
|
||||||
|
state,
|
||||||
|
updateState,
|
||||||
|
resetCustomRuleSelections,
|
||||||
|
setMethodSectionsPinCommitted,
|
||||||
|
} = useCreateFlow();
|
||||||
(window as unknown as { __updateState: typeof updateState }).__updateState =
|
(window as unknown as { __updateState: typeof updateState }).__updateState =
|
||||||
updateState;
|
updateState;
|
||||||
(
|
(
|
||||||
window as unknown as { __resetCustomRule: typeof resetCustomRuleSelections }
|
window as unknown as { __resetCustomRule: typeof resetCustomRuleSelections }
|
||||||
).__resetCustomRule = resetCustomRuleSelections;
|
).__resetCustomRule = resetCustomRuleSelections;
|
||||||
|
(
|
||||||
|
window as unknown as {
|
||||||
|
__setPin: typeof setMethodSectionsPinCommitted;
|
||||||
|
}
|
||||||
|
).__setPin = setMethodSectionsPinCommitted;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div data-testid="title">{state.title ?? ""}</div>
|
<div data-testid="title">{state.title ?? ""}</div>
|
||||||
@@ -33,6 +43,9 @@ function Harness() {
|
|||||||
<div data-testid="snapshot">
|
<div data-testid="snapshot">
|
||||||
{(state.coreValuesChipsSnapshot ?? []).map((r) => r.id).join(",")}
|
{(state.coreValuesChipsSnapshot ?? []).map((r) => r.id).join(",")}
|
||||||
</div>
|
</div>
|
||||||
|
<div data-testid="pin-method">
|
||||||
|
{state.methodSectionsPinCommitted?.communication ? "yes" : "no"}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -79,6 +92,12 @@ describe("CreateFlowContext — resetCustomRuleSelections", () => {
|
|||||||
"consensus-decision-making",
|
"consensus-decision-making",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
(window as unknown as { __setPin: (s: unknown, v: boolean) => void })
|
||||||
|
.__setPin("communication", true);
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("pin-method").textContent).toBe("yes");
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
getResetCustomRule()();
|
getResetCustomRule()();
|
||||||
});
|
});
|
||||||
@@ -88,6 +107,7 @@ describe("CreateFlowContext — resetCustomRuleSelections", () => {
|
|||||||
expect(screen.getByTestId("comm").textContent).toBe("");
|
expect(screen.getByTestId("comm").textContent).toBe("");
|
||||||
expect(screen.getByTestId("details").textContent).toBe("");
|
expect(screen.getByTestId("details").textContent).toBe("");
|
||||||
expect(screen.getByTestId("snapshot").textContent).toBe("");
|
expect(screen.getByTestId("snapshot").textContent).toBe("");
|
||||||
|
expect(screen.getByTestId("pin-method").textContent).toBe("no");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is a no-op when no custom-rule selections were set", () => {
|
it("is a no-op when no custom-rule selections were set", () => {
|
||||||
|
|||||||
@@ -205,4 +205,16 @@ describe("Rule Component", () => {
|
|||||||
|
|
||||||
expect(screen.getByText("CE")).toBeInTheDocument();
|
expect(screen.getByText("CE")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows template recommended tag when collapsed and recommended is true", () => {
|
||||||
|
render(<Rule {...defaultProps} recommended />);
|
||||||
|
|
||||||
|
expect(screen.getByText("RECOMMENDED")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show template recommended tag when expanded", () => {
|
||||||
|
render(<Rule {...defaultProps} recommended expanded categories={[]} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("RECOMMENDED")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createFlowStepForFacetGroup } from "../../app/(app)/create/utils/facetGroupToCreateFlowStep";
|
||||||
|
|
||||||
|
describe("createFlowStepForFacetGroup", () => {
|
||||||
|
it("maps facet keys to custom-rule URL segments", () => {
|
||||||
|
expect(createFlowStepForFacetGroup("coreValues")).toBe("core-values");
|
||||||
|
expect(createFlowStepForFacetGroup("communication")).toBe(
|
||||||
|
"communication-methods",
|
||||||
|
);
|
||||||
|
expect(createFlowStepForFacetGroup("membership")).toBe("membership-methods");
|
||||||
|
expect(createFlowStepForFacetGroup("decisionApproaches")).toBe(
|
||||||
|
"decision-approaches",
|
||||||
|
);
|
||||||
|
expect(createFlowStepForFacetGroup("conflictManagement")).toBe(
|
||||||
|
"conflict-management",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getPreviousStep,
|
getPreviousStep,
|
||||||
isValidStep,
|
isValidStep,
|
||||||
getStepIndex,
|
getStepIndex,
|
||||||
|
parseReviewReturnSearchParam,
|
||||||
resolveCreateFlowBackTarget,
|
resolveCreateFlowBackTarget,
|
||||||
} from "../../app/(app)/create/utils/flowSteps";
|
} from "../../app/(app)/create/utils/flowSteps";
|
||||||
|
|
||||||
@@ -104,4 +105,21 @@ describe("flowSteps", () => {
|
|||||||
"/create/review-template/mutual-aid?fromFlow=1",
|
"/create/review-template/mutual-aid?fromFlow=1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parseReviewReturnSearchParam accepts only final-review and edit-rule", () => {
|
||||||
|
expect(
|
||||||
|
parseReviewReturnSearchParam(
|
||||||
|
new URLSearchParams("reviewReturn=final-review"),
|
||||||
|
),
|
||||||
|
).toBe("final-review");
|
||||||
|
expect(
|
||||||
|
parseReviewReturnSearchParam(
|
||||||
|
new URLSearchParams("reviewReturn=edit-rule"),
|
||||||
|
),
|
||||||
|
).toBe("edit-rule");
|
||||||
|
expect(
|
||||||
|
parseReviewReturnSearchParam(new URLSearchParams("reviewReturn=nope")),
|
||||||
|
).toBeNull();
|
||||||
|
expect(parseReviewReturnSearchParam(null)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
mergeCompactCardIdsWithPinnedSelected,
|
||||||
|
orderRankedMethodsWithPinnedSelection,
|
||||||
|
} from "../../lib/create/methodCardDisplayOrder";
|
||||||
|
|
||||||
|
describe("orderRankedMethodsWithPinnedSelection", () => {
|
||||||
|
const methods = [
|
||||||
|
{ id: "a", rank: "top" },
|
||||||
|
{ id: "b", rank: "mid" },
|
||||||
|
{ id: "c", rank: "low" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns ranked order when pinning is inactive", () => {
|
||||||
|
expect(
|
||||||
|
orderRankedMethodsWithPinnedSelection(methods, ["c", "b"], false),
|
||||||
|
).toEqual(methods);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ranked order when there are no selections", () => {
|
||||||
|
expect(orderRankedMethodsWithPinnedSelection(methods, [], true)).toEqual(
|
||||||
|
methods,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins selected ids ahead of ranked tail while preserving ranking in each block", () => {
|
||||||
|
expect(
|
||||||
|
orderRankedMethodsWithPinnedSelection(methods, ["c", "a"], true),
|
||||||
|
).toEqual([
|
||||||
|
{ id: "c", rank: "low" },
|
||||||
|
{ id: "a", rank: "top" },
|
||||||
|
{ id: "b", rank: "mid" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes repeated ids in the selection list", () => {
|
||||||
|
expect(
|
||||||
|
orderRankedMethodsWithPinnedSelection(methods, ["b", "b", "c"], true),
|
||||||
|
).toEqual([
|
||||||
|
{ id: "b", rank: "mid" },
|
||||||
|
{ id: "c", rank: "low" },
|
||||||
|
{ id: "a", rank: "top" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeCompactCardIdsWithPinnedSelected", () => {
|
||||||
|
const showcaseOrder = ["x", "a", "b", "y", "c"];
|
||||||
|
const baseCompact = ["a", "y", "b"];
|
||||||
|
|
||||||
|
it("delegates when pinning is inactive", () => {
|
||||||
|
expect(
|
||||||
|
mergeCompactCardIdsWithPinnedSelected(
|
||||||
|
showcaseOrder,
|
||||||
|
baseCompact,
|
||||||
|
["x"],
|
||||||
|
false,
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
).toEqual(["a", "y", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pads selected-first then facet-derived compact slots", () => {
|
||||||
|
expect(
|
||||||
|
mergeCompactCardIdsWithPinnedSelected(
|
||||||
|
showcaseOrder,
|
||||||
|
baseCompact,
|
||||||
|
["c"],
|
||||||
|
true,
|
||||||
|
4,
|
||||||
|
),
|
||||||
|
).toEqual(["c", "a", "y", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills remaining compact slots from showcase tail when facets run short", () => {
|
||||||
|
expect(
|
||||||
|
mergeCompactCardIdsWithPinnedSelected(showcaseOrder, [], ["x"], true, 3),
|
||||||
|
).toEqual(["x", "a", "b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user