From 7fde82a94ca08574d35b7eb115c8d0b25ed86290 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:39:08 -0600 Subject: [PATCH] Pin selected after edit --- app/(app)/create/CreateFlowLayoutClient.tsx | 4 +- .../create/context/CreateFlowContext.tsx | 25 ++-- .../create/hooks/useTemplateReviewActions.ts | 118 +++++++++++------- app/(app)/create/types.ts | 11 +- lib/create/stripCustomRuleSelectionFields.ts | 22 ++++ .../stripCustomRuleSelectionFields.test.ts | 32 +++++ 6 files changed, 147 insertions(+), 65 deletions(-) create mode 100644 lib/create/stripCustomRuleSelectionFields.ts create mode 100644 tests/unit/stripCustomRuleSelectionFields.test.ts diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 4f98f3e..0c19c8b 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -135,7 +135,7 @@ function CreateFlowLayoutContent({ } = useCreateFlowNavigation( skipCommunitySave ? { skipCommunitySave: true } : undefined, ); - const { state, clearState, updateState, resetCustomRuleSelections, setMethodSectionsPinCommitted } = + const { state, clearState, updateState, resetCustomRuleSelections, setMethodSectionsPinCommitted, replaceState } = useCreateFlow(); const { draftSaveBannerMessage, setDraftSaveBannerMessage } = useCreateFlowDraftSaveBanner(); @@ -177,7 +177,7 @@ function CreateFlowLayoutContent({ pathname, state, updateState, - resetCustomRuleSelections, + replaceState, router, }); diff --git a/app/(app)/create/context/CreateFlowContext.tsx b/app/(app)/create/context/CreateFlowContext.tsx index 6d42b83..5b9beb1 100644 --- a/app/(app)/create/context/CreateFlowContext.tsx +++ b/app/(app)/create/context/CreateFlowContext.tsx @@ -21,6 +21,7 @@ import { readAnonymousCreateFlowState, writeAnonymousCreateFlowState, } from "../utils/anonymousDraftStorage"; +import { stripCustomRuleSelectionFields } from "../../../../lib/create/stripCustomRuleSelectionFields"; import { clearCoreValueDetailsLocalStorage, readCoreValueDetailsFromLocalStorage, @@ -170,9 +171,12 @@ export function CreateFlowProvider({ }); }, []); - const replaceState = useCallback((next: CreateFlowState) => { - setState(next); - }, []); + const replaceState = useCallback( + (next: CreateFlowState | ((prev: CreateFlowState) => CreateFlowState)) => { + setState(next); + }, + [], + ); const clearState = useCallback(() => { setState({}); @@ -184,20 +188,7 @@ export function CreateFlowProvider({ // Keys produced by the Create Custom stage screens + `buildTemplateCustomizePrefill`. // Kept in sync with `CreateFlowState` comments marked "Create Custom —". const resetCustomRuleSelections = useCallback(() => { - setState((prev) => { - const { - selectedCoreValueIds: _a, - coreValuesChipsSnapshot: _b, - coreValueDetailsByChipId: _c, - selectedCommunicationMethodIds: _d, - selectedMembershipMethodIds: _e, - selectedDecisionApproachIds: _f, - selectedConflictManagementIds: _g, - methodSectionsPinCommitted: _h, - ...rest - } = prev; - return rest; - }); + setState((prev) => stripCustomRuleSelectionFields(prev)); // Effect on `state.coreValueDetailsByChipId` clears its dedicated // localStorage key when the field goes undefined, so we don't need to // touch `clearCoreValueDetailsLocalStorage()` directly here. diff --git a/app/(app)/create/hooks/useTemplateReviewActions.ts b/app/(app)/create/hooks/useTemplateReviewActions.ts index 2bc3bbc..be976ad 100644 --- a/app/(app)/create/hooks/useTemplateReviewActions.ts +++ b/app/(app)/create/hooks/useTemplateReviewActions.ts @@ -1,16 +1,17 @@ "use client"; import { useCallback, useMemo, useState } from "react"; -import { - buildCoreValuesPrefillFromTemplateBody, - buildTemplateCustomizePrefill, -} from "../../../../lib/create/applyTemplatePrefill"; +import { buildTemplateCustomizePrefill } from "../../../../lib/create/applyTemplatePrefill"; import { loadTemplateReviewBySlug } from "../../../../lib/create/loadTemplateReviewBySlug"; +import { stripCustomRuleSelectionFields } from "../../../../lib/create/stripCustomRuleSelectionFields"; import messages from "../../../../messages/en/index"; import type { CreateFlowState } from "../types"; type AppRouterLike = { push: (_href: string) => void }; type UpdateState = (_patch: Partial) => void; +type ReplaceStateFn = ( + _next: CreateFlowState | ((_prev: CreateFlowState) => CreateFlowState), +) => void; export type UseTemplateReviewActionsResult = { /** True iff the current pathname is a template-review route (locale/basePath tolerant). */ @@ -30,11 +31,12 @@ export type UseTemplateReviewActionsResult = { */ handleCustomize: () => Promise; /** - * Use without changes: scrub any prior customize picks, seed the core-values - * snapshot from the template's Values section, drop that section from - * `state.sections`, and route to `/create/confirm-stakeholders` (or - * `/create/informational` with a pin to skip past `/create/review` to - * `/create/confirm-stakeholders` later). + * Use without changes: scrub any prior customize picks, seed core values + + * method-card selections from the template body (same id mapping as + * Customize) so drilling from final-review via + shows selected cards, drop + * the Values row from `state.sections`, and route to + * `/create/confirm-stakeholders` (or `/create/informational` with a pin to + * skip past `/create/review` to `/create/confirm-stakeholders` later). */ handleUseWithoutChanges: () => Promise; }; @@ -55,19 +57,19 @@ export type UseTemplateReviewActionsResult = { * setTemplateReviewApplyError, * handleCustomize, * handleUseWithoutChanges, - * } = useTemplateReviewActions({ pathname, state, updateState, resetCustomRuleSelections, router }); + * } = useTemplateReviewActions({ pathname, state, updateState, replaceState, router }); */ export function useTemplateReviewActions({ pathname, state, updateState, - resetCustomRuleSelections, + replaceState, router, }: { pathname: string | null | undefined; state: CreateFlowState; updateState: UpdateState; - resetCustomRuleSelections: () => void; + replaceState: ReplaceStateFn; router: AppRouterLike; }): UseTemplateReviewActionsResult { const [isApplyingTemplate, setIsApplyingTemplate] = useState(false); @@ -143,18 +145,19 @@ export function useTemplateReviewActions({ return; } - // Using the template verbatim: scrub any prior customize picks so they - // don't bleed into `document.coreValues` at publish time. - resetCustomRuleSelections(); + const hasCommunityName = + typeof state.title === "string" && state.title.trim().length > 0; - // Seed the core-values snapshot from the Values section so the - // final-review chip modal can edit them (it keys edits by chip id). - // The Values entries themselves are then dropped from `sections` to - // avoid publishing `document.coreValues` and `document.sections.Values` - // for the same data — matches the "Customize" path's data shape. - const coreValuesPrefill = buildCoreValuesPrefillFromTemplateBody(doc); - const sectionsWithoutValues = - Object.keys(coreValuesPrefill).length > 0 + // Atomic read-modify-write: strip prior custom-rule picks and merge template + // body in one replaceState so method ids are never lost across React batching + // (reset + update separately could leave selections undefined in Strict Mode). + replaceState((prev) => { + const base = stripCustomRuleSelectionFields(prev); + const customizePrefill = buildTemplateCustomizePrefill(doc); + const hasValuesSeed = + customizePrefill.selectedCoreValueIds !== undefined; + + const sectionsWithoutValues = hasValuesSeed ? sections.filter((s) => { const name = (s as { categoryName?: unknown }).categoryName; if (typeof name !== "string") return true; @@ -163,33 +166,60 @@ export function useTemplateReviewActions({ }) : sections; - const hasCommunityName = - typeof state.title === "string" && state.title.trim().length > 0; - updateState({ - ...coreValuesPrefill, - sections: sectionsWithoutValues, - templateReviewBackSlug: templateReviewSlug, - ...(hasCommunityName - ? { pendingTemplateAction: undefined } - : { - pendingTemplateAction: { - slug: templateReviewSlug, - mode: "useWithoutChanges", - }, - }), + const hasCommunityName = + typeof prev.title === "string" && prev.title.trim().length > 0; + + return { + ...base, + ...(hasValuesSeed + ? { + selectedCoreValueIds: customizePrefill.selectedCoreValueIds, + coreValuesChipsSnapshot: + customizePrefill.coreValuesChipsSnapshot, + } + : {}), + ...(customizePrefill.selectedCommunicationMethodIds !== undefined + ? { + selectedCommunicationMethodIds: + customizePrefill.selectedCommunicationMethodIds, + } + : {}), + ...(customizePrefill.selectedMembershipMethodIds !== undefined + ? { + selectedMembershipMethodIds: + customizePrefill.selectedMembershipMethodIds, + } + : {}), + ...(customizePrefill.selectedDecisionApproachIds !== undefined + ? { + selectedDecisionApproachIds: + customizePrefill.selectedDecisionApproachIds, + } + : {}), + ...(customizePrefill.selectedConflictManagementIds !== undefined + ? { + selectedConflictManagementIds: + customizePrefill.selectedConflictManagementIds, + } + : {}), + sections: sectionsWithoutValues, + templateReviewBackSlug: templateReviewSlug, + ...(hasCommunityName + ? { pendingTemplateAction: undefined } + : { + pendingTemplateAction: { + slug: templateReviewSlug, + mode: "useWithoutChanges", + }, + }), + }; }); router.push( hasCommunityName ? "/create/confirm-stakeholders" : "/create/informational", ); - }, [ - resetCustomRuleSelections, - router, - state.title, - templateReviewSlug, - updateState, - ]); + }, [replaceState, router, state.title, templateReviewSlug]); return { isTemplateReviewRoute, diff --git a/app/(app)/create/types.ts b/app/(app)/create/types.ts index a86fde9..57ca0b8 100644 --- a/app/(app)/create/types.ts +++ b/app/(app)/create/types.ts @@ -212,8 +212,15 @@ export interface CreateFlowContextValue { state: CreateFlowState; currentStep: CreateFlowStep | null; updateState: (_updates: Partial) => void; - /** Replace entire flow state (e.g. hydrate from server draft). */ - replaceState: (_next: CreateFlowState) => void; + /** + * Replace entire flow state (e.g. hydrate from server draft), or compute the + * next state from the previous snapshot (atomic read-modify-write). + */ + replaceState: ( + _next: + | CreateFlowState + | ((_prev: CreateFlowState) => CreateFlowState), + ) => void; /** Reset flow state and clear anonymous localStorage draft keys when present. */ clearState: () => void; /** diff --git a/lib/create/stripCustomRuleSelectionFields.ts b/lib/create/stripCustomRuleSelectionFields.ts new file mode 100644 index 0000000..1b7e2c9 --- /dev/null +++ b/lib/create/stripCustomRuleSelectionFields.ts @@ -0,0 +1,22 @@ +import type { CreateFlowState } from "../../app/(app)/create/types"; + +/** + * Same field removal as {@link resetCustomRuleSelections} in CreateFlowProvider. + * Used to apply template "Use without changes" in one atomic replaceState updater. + */ +export function stripCustomRuleSelectionFields( + prev: CreateFlowState, +): CreateFlowState { + const { + selectedCoreValueIds: _a, + coreValuesChipsSnapshot: _b, + coreValueDetailsByChipId: _c, + selectedCommunicationMethodIds: _d, + selectedMembershipMethodIds: _e, + selectedDecisionApproachIds: _f, + selectedConflictManagementIds: _g, + methodSectionsPinCommitted: _h, + ...rest + } = prev; + return rest; +} diff --git a/tests/unit/stripCustomRuleSelectionFields.test.ts b/tests/unit/stripCustomRuleSelectionFields.test.ts new file mode 100644 index 0000000..800cb87 --- /dev/null +++ b/tests/unit/stripCustomRuleSelectionFields.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { stripCustomRuleSelectionFields } from "../../lib/create/stripCustomRuleSelectionFields"; +import type { CreateFlowState } from "../../app/(app)/create/types"; + +describe("stripCustomRuleSelectionFields", () => { + it("removes custom-rule selection facets and preserves community + template sections", () => { + const prev: CreateFlowState = { + title: "Garden", + communityContext: "...", + selectedCoreValueIds: ["1"], + coreValuesChipsSnapshot: [{ id: "1", label: "X", state: "selected" }], + selectedCommunicationMethodIds: ["signal"], + selectedMembershipMethodIds: ["x"], + selectedDecisionApproachIds: ["y"], + selectedConflictManagementIds: ["z"], + methodSectionsPinCommitted: { communication: true }, + coreValueDetailsByChipId: { "1": { meaning: "", signals: "" } }, + sections: [{ categoryName: "Communication", entries: [] }], + }; + const out = stripCustomRuleSelectionFields(prev); + expect(out.title).toBe("Garden"); + expect(out.sections).toEqual(prev.sections); + expect(out.selectedCoreValueIds).toBeUndefined(); + expect(out.coreValuesChipsSnapshot).toBeUndefined(); + expect(out.selectedCommunicationMethodIds).toBeUndefined(); + expect(out.selectedMembershipMethodIds).toBeUndefined(); + expect(out.selectedDecisionApproachIds).toBeUndefined(); + expect(out.selectedConflictManagementIds).toBeUndefined(); + expect(out.methodSectionsPinCommitted).toBeUndefined(); + expect(out.coreValueDetailsByChipId).toBeUndefined(); + }); +});