Edit flow configured
This commit is contained in:
@@ -7,7 +7,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
||||
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
||||
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
|
||||
@@ -15,7 +15,7 @@ import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
|
||||
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
|
||||
import CreateFlowFooter from "../../components/navigation/CreateFlowFooter";
|
||||
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 {
|
||||
createFlowStepUsesCenteredTextLayout,
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "./utils/createFlowFooterClassNames";
|
||||
import {
|
||||
CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
|
||||
methodCardFacetSectionForConfirmStep,
|
||||
type CustomRuleConfirmFooterStep,
|
||||
} from "./utils/customRuleConfirmFooterSteps";
|
||||
import { getDefaultFooterLabel } from "./utils/createFlowFooterLabels";
|
||||
@@ -111,6 +112,8 @@ function CreateFlowLayoutContent({
|
||||
const tLogin = useTranslation("pages.login");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const reviewReturnTarget = parseReviewReturnSearchParam(searchParams);
|
||||
const { openLogin } = useAuthModal();
|
||||
const skipCommunitySave = sessionResolved && Boolean(sessionUser);
|
||||
const {
|
||||
@@ -123,7 +126,7 @@ function CreateFlowLayoutContent({
|
||||
} = useCreateFlowNavigation(
|
||||
skipCommunitySave ? { skipCommunitySave: true } : undefined,
|
||||
);
|
||||
const { state, clearState, updateState, resetCustomRuleSelections } =
|
||||
const { state, clearState, updateState, resetCustomRuleSelections, setMethodSectionsPinCommitted } =
|
||||
useCreateFlow();
|
||||
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
|
||||
useCreateFlowDraftSaveBanner();
|
||||
@@ -253,13 +256,19 @@ function CreateFlowLayoutContent({
|
||||
if (titleOk && editingId === last.id && sectionsClear) {
|
||||
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,
|
||||
router,
|
||||
updateState,
|
||||
state.editingPublishedRuleId,
|
||||
state.title,
|
||||
state.methodSectionsPinCommitted,
|
||||
state.sections?.length,
|
||||
]);
|
||||
|
||||
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
|
||||
@@ -348,6 +357,13 @@ function CreateFlowLayoutContent({
|
||||
currentStep != null
|
||||
? CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP.get(currentStep)
|
||||
: 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
|
||||
@@ -590,7 +606,8 @@ function CreateFlowLayoutContent({
|
||||
{footer.createFromTemplate}
|
||||
</Button>
|
||||
</div>
|
||||
) : customRuleConfirmFooter && nextStep ? (
|
||||
) : showCustomRuleFooterConfirm &&
|
||||
customRuleConfirmFooter ? (
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
@@ -601,6 +618,24 @@ function CreateFlowLayoutContent({
|
||||
}
|
||||
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
|
||||
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();
|
||||
}}
|
||||
>
|
||||
@@ -638,9 +673,22 @@ function CreateFlowLayoutContent({
|
||||
? "/create/review"
|
||||
: "/",
|
||||
)
|
||||
: previousStep
|
||||
? goToPreviousStep
|
||||
: undefined
|
||||
: 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
|
||||
? goToPreviousStep
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -77,6 +77,12 @@ export function SignedInDraftHydration({
|
||||
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;
|
||||
setLoadingHydration(true);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type {
|
||||
CreateFlowMethodCardFacetSection,
|
||||
CreateFlowState,
|
||||
CreateFlowContextValue,
|
||||
CreateFlowStep,
|
||||
@@ -137,6 +138,19 @@ export function CreateFlowProvider({
|
||||
setInteractionTouched(true);
|
||||
}, []);
|
||||
|
||||
const setMethodSectionsPinCommitted = useCallback(
|
||||
(section: CreateFlowMethodCardFacetSection, committed: boolean) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
methodSectionsPinCommitted: {
|
||||
...(prevState.methodSectionsPinCommitted ?? {}),
|
||||
[section]: committed,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateState = useCallback((updates: Partial<CreateFlowState>) => {
|
||||
setState((prevState) => {
|
||||
const merged: CreateFlowState = { ...prevState, ...updates };
|
||||
@@ -179,6 +193,7 @@ export function CreateFlowProvider({
|
||||
selectedMembershipMethodIds: _e,
|
||||
selectedDecisionApproachIds: _f,
|
||||
selectedConflictManagementIds: _g,
|
||||
methodSectionsPinCommitted: _h,
|
||||
...rest
|
||||
} = prev;
|
||||
return rest;
|
||||
@@ -195,6 +210,7 @@ export function CreateFlowProvider({
|
||||
replaceState,
|
||||
clearState,
|
||||
resetCustomRuleSelections,
|
||||
setMethodSectionsPinCommitted,
|
||||
interactionTouched,
|
||||
markCreateFlowInteraction,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import type { CreateFlowStep } from "../types";
|
||||
import {
|
||||
type CreateFlowNavigationOptions,
|
||||
type CreateFlowReviewReturnTarget,
|
||||
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||
buildTemplateReviewHref,
|
||||
getNextStep,
|
||||
getPreviousStep,
|
||||
@@ -46,7 +48,10 @@ export function useCreateFlowNavigation(
|
||||
currentStep: CreateFlowStep | null;
|
||||
goToNextStep: () => void;
|
||||
goToPreviousStep: () => void;
|
||||
goToStep: (_step: CreateFlowStep) => void;
|
||||
goToStep: (
|
||||
_step: CreateFlowStep,
|
||||
_navOpts?: { reviewReturn?: CreateFlowReviewReturnTarget },
|
||||
) => void;
|
||||
canGoNext: () => boolean;
|
||||
canGoBack: () => boolean;
|
||||
nextStep: CreateFlowStep | null;
|
||||
@@ -122,11 +127,21 @@ export function useCreateFlowNavigation(
|
||||
);
|
||||
|
||||
const goToStep = useCallback(
|
||||
(step: CreateFlowStep) => {
|
||||
(
|
||||
step: CreateFlowStep,
|
||||
navOpts?: { reviewReturn?: CreateFlowReviewReturnTarget },
|
||||
) => {
|
||||
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]);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import {
|
||||
deriveCompactCards,
|
||||
rankMethodsByScore,
|
||||
useFacetRecommendations,
|
||||
} from "../../hooks/useFacetRecommendations";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../../../components/cards/CardStack";
|
||||
import Create from "../../../../components/modals/Create";
|
||||
@@ -49,32 +45,10 @@ export function CommunicationMethodsScreen() {
|
||||
|
||||
const selectedIds = state.selectedCommunicationMethodIds ?? [];
|
||||
|
||||
const { scoresBySlug, hasAnyFacets } =
|
||||
useFacetRecommendations("communication");
|
||||
const rankedMethods = useMemo(
|
||||
() => rankMethodsByScore(comm.methods, scoresBySlug),
|
||||
[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 { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
||||
"communication",
|
||||
comm.methods,
|
||||
selectedIds,
|
||||
);
|
||||
|
||||
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
|
||||
|
||||
@@ -12,15 +12,11 @@
|
||||
* 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 { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import {
|
||||
deriveCompactCards,
|
||||
rankMethodsByScore,
|
||||
useFacetRecommendations,
|
||||
} from "../../hooks/useFacetRecommendations";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../../../components/cards/CardStack";
|
||||
import Create from "../../../../components/modals/Create";
|
||||
@@ -47,32 +43,10 @@ export function ConflictManagementScreen() {
|
||||
|
||||
const selectedIds = state.selectedConflictManagementIds ?? [];
|
||||
|
||||
const { scoresBySlug, hasAnyFacets } =
|
||||
useFacetRecommendations("conflictManagement");
|
||||
const rankedMethods = useMemo(
|
||||
() => rankMethodsByScore(cm.methods, scoresBySlug),
|
||||
[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 { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
||||
"conflictManagement",
|
||||
cm.methods,
|
||||
selectedIds,
|
||||
);
|
||||
|
||||
const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
|
||||
|
||||
@@ -13,15 +13,11 @@
|
||||
* DB-driven content.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import {
|
||||
deriveCompactCards,
|
||||
rankMethodsByScore,
|
||||
useFacetRecommendations,
|
||||
} from "../../hooks/useFacetRecommendations";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||
import CardStack from "../../../../components/cards/CardStack";
|
||||
import Create from "../../../../components/modals/Create";
|
||||
@@ -48,32 +44,10 @@ export function MembershipMethodsScreen() {
|
||||
|
||||
const selectedIds = state.selectedMembershipMethodIds ?? [];
|
||||
|
||||
const { scoresBySlug, hasAnyFacets } =
|
||||
useFacetRecommendations("membership");
|
||||
const rankedMethods = useMemo(
|
||||
() => rankMethodsByScore(mem.methods, scoresBySlug),
|
||||
[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 { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
||||
"membership",
|
||||
mem.methods,
|
||||
selectedIds,
|
||||
);
|
||||
|
||||
const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
|
||||
|
||||
@@ -15,18 +15,31 @@ import {
|
||||
type FinalReviewCategoryRowDetailed,
|
||||
} from "../../../../../lib/create/buildFinalReviewCategories";
|
||||
import { applyFinalReviewChipEditPatch } from "../../../../../lib/create/applyFinalReviewChipEditPatch";
|
||||
import type { TemplateChipDetail } from "../../../../../lib/create/templateReviewMapping";
|
||||
import type {
|
||||
TemplateChipDetail,
|
||||
TemplateFacetGroupKey,
|
||||
} from "../../../../../lib/create/templateReviewMapping";
|
||||
import {
|
||||
FinalReviewChipEditModal,
|
||||
type FinalReviewChipEditPatch,
|
||||
type FinalReviewChipEditTarget,
|
||||
} from "../../components/FinalReviewChipEditModal";
|
||||
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
|
||||
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
|
||||
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
|
||||
import {
|
||||
getAssetPath,
|
||||
vectorMarkPath,
|
||||
} 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
|
||||
* (Values / Communication / Membership / Decision-making / Conflict
|
||||
@@ -75,6 +88,7 @@ export function FinalReviewScreen({
|
||||
variant?: "default" | "editPublished";
|
||||
} = {}) {
|
||||
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
|
||||
const { goToStep } = useCreateFlowNavigation();
|
||||
const mdUp = useCreateFlowMdUp();
|
||||
const t = useTranslation("create.reviewAndComplete.finalReview");
|
||||
const m = useMessages();
|
||||
@@ -116,13 +130,23 @@ export function FinalReviewScreen({
|
||||
const derived = buildFinalReviewCategoryRowsDetailed(state, names);
|
||||
const rowsToRender: readonly FinalReviewCategoryRowDetailed[] =
|
||||
derived.length > 0 ? derived : fallbackRows;
|
||||
const usingFallbackRows = derived.length === 0;
|
||||
|
||||
const lookup = new Map<
|
||||
string,
|
||||
{ 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 chipId = `${row.name}-${idx}`;
|
||||
const readOnly: TemplateChipDetail = {
|
||||
@@ -150,6 +174,7 @@ export function FinalReviewScreen({
|
||||
return {
|
||||
name: row.name,
|
||||
chipOptions,
|
||||
addButton: effectiveGroupKey != null,
|
||||
onChipClick: (_categoryName: string, chipId: string) => {
|
||||
const hit = lookup.get(chipId);
|
||||
if (!hit) return;
|
||||
@@ -160,6 +185,15 @@ export function FinalReviewScreen({
|
||||
setActiveReadOnlyDetail(hit.readOnly);
|
||||
}
|
||||
},
|
||||
onAddClick:
|
||||
effectiveGroupKey != null
|
||||
? () => {
|
||||
markCreateFlowInteraction();
|
||||
goToStep(createFlowStepForFacetGroup(effectiveGroupKey), {
|
||||
reviewReturn,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
return { categories: cats, chipLookup: lookup };
|
||||
@@ -167,6 +201,8 @@ export function FinalReviewScreen({
|
||||
m.create.reviewAndComplete.finalReview.categories,
|
||||
state,
|
||||
markCreateFlowInteraction,
|
||||
goToStep,
|
||||
variant,
|
||||
]);
|
||||
void chipLookup;
|
||||
|
||||
|
||||
@@ -23,15 +23,10 @@ import Create from "../../../../components/modals/Create";
|
||||
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||
import InfoMessageBox from "../../../../components/controls/InfoMessageBox";
|
||||
import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMessageBox/InfoMessageBox.types";
|
||||
import type { CardStackItem } from "../../../../components/cards/CardStack/CardStack.types";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||
import {
|
||||
deriveCompactCards,
|
||||
rankMethodsByScore,
|
||||
useFacetRecommendations,
|
||||
} from "../../hooks/useFacetRecommendations";
|
||||
import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering";
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
import { DecisionApproachEditFields } from "../../components/methodEditFields";
|
||||
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||
@@ -62,32 +57,10 @@ export function DecisionApproachesScreen() {
|
||||
[da.messageBox.items],
|
||||
);
|
||||
|
||||
const { scoresBySlug, hasAnyFacets } =
|
||||
useFacetRecommendations("decisionApproaches");
|
||||
const rankedMethods = useMemo(
|
||||
() => rankMethodsByScore(da.methods, scoresBySlug),
|
||||
[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 { sampleCards, compactCardIds, methodById } = useMethodCardDeckOrdering(
|
||||
"decisionApproaches",
|
||||
da.methods,
|
||||
selectedIds,
|
||||
);
|
||||
|
||||
const sidebarDescription = (
|
||||
|
||||
@@ -36,6 +36,13 @@ export type CreateFlowTextStateField =
|
||||
| "communityContext"
|
||||
| "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).
|
||||
* Stored in drafts so custom chips survive refresh and server sync.
|
||||
@@ -130,6 +137,14 @@ export interface CreateFlowState {
|
||||
selectedDecisionApproachIds?: string[];
|
||||
/** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.customRule.conflictManagement` presets. */
|
||||
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
|
||||
* (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.
|
||||
*/
|
||||
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
|
||||
* 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";
|
||||
|
||||
type FooterMessageKey = keyof typeof footerMessages;
|
||||
@@ -67,3 +71,24 @@ export const CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP: ReadonlyMap<
|
||||
CreateFlowStep,
|
||||
CustomRuleConfirmFooterStep
|
||||
> = 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_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
|
||||
* footer Back to `/create/review` instead of marketing home.
|
||||
|
||||
@@ -351,6 +351,11 @@ export function ProfilePageView({
|
||||
{
|
||||
id: "view",
|
||||
label: t("viewPublic"),
|
||||
href: `/rules/${encodeURIComponent(rule.id)}`,
|
||||
},
|
||||
{
|
||||
id: "manage",
|
||||
label: t("manageRule"),
|
||||
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 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({
|
||||
orderBy: { createdAt: "desc" },
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -42,6 +42,7 @@ const RuleContainer = memo<RuleProps>(
|
||||
hasBottomLinks = false,
|
||||
bottomStatusLabel,
|
||||
bottomLinks,
|
||||
recommended = false,
|
||||
}) => {
|
||||
const size = sizeProp ?? "L";
|
||||
|
||||
@@ -96,6 +97,7 @@ const RuleContainer = memo<RuleProps>(
|
||||
hasBottomLinks={hasBottomLinks}
|
||||
bottomStatusLabel={bottomStatusLabel}
|
||||
bottomLinks={bottomLinks}
|
||||
recommended={recommended}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { RuleSizeValue } from "../../../../lib/propNormalization";
|
||||
export interface Category {
|
||||
name: string;
|
||||
chipOptions: ChipOption[];
|
||||
/** When `false`, hide the row’s + affordance. Default: show when the Rule allows adds. */
|
||||
addButton?: boolean;
|
||||
onChipClick?: (categoryName: string, chipId: string) => void;
|
||||
onAddClick?: (categoryName: string) => void;
|
||||
onCustomChipConfirm?: (
|
||||
@@ -58,6 +60,12 @@ export interface RuleProps {
|
||||
/** Uppercase chip (e.g. IN PROGRESS); omit when no left badge. */
|
||||
bottomStatusLabel?: string;
|
||||
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 {
|
||||
@@ -81,4 +89,5 @@ export interface RuleViewProps {
|
||||
hasBottomLinks?: boolean;
|
||||
bottomStatusLabel?: string;
|
||||
bottomLinks?: RuleBottomLink[];
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import MultiSelect from "../../controls/MultiSelect";
|
||||
import InlineTextButton from "../../buttons/InlineTextButton";
|
||||
import NavigationLink from "../../navigation/Link";
|
||||
import Tag from "../../utility/Tag";
|
||||
import type { RuleBottomLink, RuleViewProps } from "./Rule.types";
|
||||
|
||||
export function RuleView({
|
||||
@@ -28,6 +29,7 @@ export function RuleView({
|
||||
hasBottomLinks = false,
|
||||
bottomStatusLabel,
|
||||
bottomLinks,
|
||||
recommended = false,
|
||||
}: RuleViewProps) {
|
||||
const t = useTranslation("ruleCard");
|
||||
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
|
||||
@@ -90,6 +92,7 @@ export function RuleView({
|
||||
`;
|
||||
|
||||
// Title typography - use CSS responsive classes
|
||||
const showRecommendedTag = recommended && !expanded;
|
||||
const titleClass = `
|
||||
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]
|
||||
@@ -256,12 +259,22 @@ export function RuleView({
|
||||
{/* Inner container for header text with padding */}
|
||||
<div
|
||||
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]
|
||||
min-[640px]:max-[1023px]:pl-[12px] min-[640px]:max-[1023px]:py-[12px]
|
||||
min-[1024px]:px-[16px] min-[1024px]:py-[24px]
|
||||
`}
|
||||
>
|
||||
{showRecommendedTag ? (
|
||||
<Tag variant="templateRecommended">
|
||||
{t("recommendedLabel")}
|
||||
</Tag>
|
||||
) : null}
|
||||
<h3
|
||||
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) => {
|
||||
category.onCustomChipClose?.(category.name, chipId);
|
||||
}}
|
||||
addButton={!hideCategoryAddButton}
|
||||
addButton={
|
||||
!hideCategoryAddButton && category.addButton !== false
|
||||
}
|
||||
addButtonText="" // Empty text for icon-only circular button
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
@@ -57,6 +57,7 @@ export function GovernanceTemplateGrid({
|
||||
key={card.slug}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
recommended={card.recommended === true}
|
||||
size={cardSize}
|
||||
className={`
|
||||
select-none
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { TagProps } from "./Tag.types";
|
||||
const DEFAULT_LABELS: Record<TagProps["variant"], string> = {
|
||||
recommended: "RECOMMENDED",
|
||||
selected: "SELECTED",
|
||||
templateRecommended: "RECOMMENDED",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
export type TagVariant = "recommended" | "selected";
|
||||
export type TagVariant = "recommended" | "selected" | "templateRecommended";
|
||||
|
||||
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;
|
||||
/** Tag text. Defaults to "RECOMMENDED" or "SELECTED" when not provided. */
|
||||
children?: React.ReactNode;
|
||||
|
||||
@@ -7,12 +7,17 @@ import type { TagViewProps } from "./Tag.types";
|
||||
*/
|
||||
export function TagView({ variant, children, className }: TagViewProps) {
|
||||
const isRecommended = variant === "recommended";
|
||||
const bgClass = isRecommended
|
||||
? "bg-[var(--color-surface-inverse-brand-accent)]"
|
||||
: "bg-[var(--color-gray-1000)]";
|
||||
const textClass = isRecommended
|
||||
? "text-[var(--color-content-inverse-brand-primary)]"
|
||||
: "text-[var(--color-gray-000)]";
|
||||
const isTemplateRecommended = variant === "templateRecommended";
|
||||
const bgClass = isTemplateRecommended
|
||||
? "bg-[var(--color-surface-default-tertiary)]"
|
||||
: isRecommended
|
||||
? "bg-[var(--color-surface-inverse-brand-accent)]"
|
||||
: "bg-[var(--color-gray-1000)]";
|
||||
const textClass = isTemplateRecommended
|
||||
? "text-[var(--color-gray-000)]"
|
||||
: isRecommended
|
||||
? "text-[var(--color-content-inverse-brand-primary)]"
|
||||
: "text-[var(--color-gray-000)]";
|
||||
|
||||
return (
|
||||
<span
|
||||
|
||||
+2
-2
@@ -292,8 +292,8 @@ export type MyPublishedRule = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists the signed-in user’s published rules (newest first). Returns `null` on
|
||||
* network failure or unauthenticated response.
|
||||
* Lists the signed-in user’s published rules (**last updated first**, stable by id).
|
||||
* Returns `null` on network failure or unauthenticated response.
|
||||
*/
|
||||
export async function fetchMyPublishedRules(): Promise<
|
||||
MyPublishedRule[] | null
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
export async function listPublishedRulesForUser(
|
||||
@@ -79,7 +80,7 @@ export async function listPublishedRulesForUser(
|
||||
try {
|
||||
return await prisma.publishedRule.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ title: "asc" }, { id: "asc" }],
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
||||
take: clamped,
|
||||
select: PUBLISHED_RULE_OWNER_LIST_SELECT,
|
||||
});
|
||||
|
||||
@@ -112,6 +112,15 @@ export const createFlowStateSchema = z
|
||||
conflictManagementDetailsById: z
|
||||
.record(conflictManagementDetailEntrySchema)
|
||||
.optional(),
|
||||
methodSectionsPinCommitted: z
|
||||
.object({
|
||||
communication: z.boolean().optional(),
|
||||
membership: z.boolean().optional(),
|
||||
decisionApproaches: z.boolean().optional(),
|
||||
conflictManagement: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
pendingTemplateAction: z
|
||||
.object({
|
||||
slug: z.string().max(200),
|
||||
|
||||
@@ -13,6 +13,12 @@ export type GovernanceTemplateCatalogEntry = {
|
||||
backgroundColor: string;
|
||||
/** Path under public/ for getAssetPath() — Figma Asset / Template Mark */
|
||||
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). */
|
||||
|
||||
@@ -11,16 +11,24 @@ import {
|
||||
export const TEMPLATE_GRID_FALLBACK_PRESENTATION = {
|
||||
iconPath: governanceTemplateIconPath("consensus"),
|
||||
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
|
||||
recommended: false,
|
||||
} as const;
|
||||
|
||||
export type TemplateGridCardEntry = GovernanceTemplateCatalogEntry;
|
||||
|
||||
function presentationForSlug(slug: string): Pick<
|
||||
GovernanceTemplateCatalogEntry,
|
||||
"iconPath" | "backgroundColor"
|
||||
"iconPath" | "backgroundColor" | "recommended"
|
||||
> {
|
||||
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,
|
||||
iconPath: pres.iconPath,
|
||||
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.",
|
||||
"createTemplateCta": "Browse templates",
|
||||
"viewPublic": "View",
|
||||
"manageRule": "Manage",
|
||||
"duplicate": "Duplicate",
|
||||
"deleteRule": "Delete",
|
||||
"deleteRuleConfirm": "Delete this published rule? This cannot be undone.",
|
||||
|
||||
@@ -37,6 +37,11 @@ export default {
|
||||
control: { type: "boolean" },
|
||||
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: {
|
||||
control: { type: "select" },
|
||||
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.",
|
||||
backgroundColor: "bg-[var(--color-surface-default-brand-lime)]",
|
||||
expanded: false,
|
||||
recommended: false,
|
||||
size: "L",
|
||||
icon: (
|
||||
<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 = {
|
||||
args: {
|
||||
title: "Mutual Aid Mondays",
|
||||
|
||||
@@ -8,14 +8,14 @@ export default {
|
||||
docs: {
|
||||
description: {
|
||||
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: {
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["recommended", "selected"],
|
||||
options: ["recommended", "selected", "templateRecommended"],
|
||||
description: "Visual variant",
|
||||
},
|
||||
children: {
|
||||
@@ -37,6 +37,12 @@ export const Selected = {
|
||||
},
|
||||
};
|
||||
|
||||
export const TemplateRecommended = {
|
||||
args: {
|
||||
variant: "templateRecommended",
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomLabel = {
|
||||
args: {
|
||||
variant: "recommended",
|
||||
|
||||
@@ -12,12 +12,22 @@ import {
|
||||
* window globals. Keeps the test readable vs. threading refs everywhere.
|
||||
*/
|
||||
function Harness() {
|
||||
const { state, updateState, resetCustomRuleSelections } = useCreateFlow();
|
||||
const {
|
||||
state,
|
||||
updateState,
|
||||
resetCustomRuleSelections,
|
||||
setMethodSectionsPinCommitted,
|
||||
} = useCreateFlow();
|
||||
(window as unknown as { __updateState: typeof updateState }).__updateState =
|
||||
updateState;
|
||||
(
|
||||
window as unknown as { __resetCustomRule: typeof resetCustomRuleSelections }
|
||||
).__resetCustomRule = resetCustomRuleSelections;
|
||||
(
|
||||
window as unknown as {
|
||||
__setPin: typeof setMethodSectionsPinCommitted;
|
||||
}
|
||||
).__setPin = setMethodSectionsPinCommitted;
|
||||
return (
|
||||
<>
|
||||
<div data-testid="title">{state.title ?? ""}</div>
|
||||
@@ -33,6 +43,9 @@ function Harness() {
|
||||
<div data-testid="snapshot">
|
||||
{(state.coreValuesChipsSnapshot ?? []).map((r) => r.id).join(",")}
|
||||
</div>
|
||||
<div data-testid="pin-method">
|
||||
{state.methodSectionsPinCommitted?.communication ? "yes" : "no"}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -79,6 +92,12 @@ describe("CreateFlowContext — resetCustomRuleSelections", () => {
|
||||
"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(() => {
|
||||
getResetCustomRule()();
|
||||
});
|
||||
@@ -88,6 +107,7 @@ describe("CreateFlowContext — resetCustomRuleSelections", () => {
|
||||
expect(screen.getByTestId("comm").textContent).toBe("");
|
||||
expect(screen.getByTestId("details").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", () => {
|
||||
|
||||
@@ -205,4 +205,16 @@ describe("Rule Component", () => {
|
||||
|
||||
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,
|
||||
isValidStep,
|
||||
getStepIndex,
|
||||
parseReviewReturnSearchParam,
|
||||
resolveCreateFlowBackTarget,
|
||||
} from "../../app/(app)/create/utils/flowSteps";
|
||||
|
||||
@@ -104,4 +105,21 @@ describe("flowSteps", () => {
|
||||
"/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