Pin selected after edit

This commit is contained in:
adilallo
2026-04-29 19:39:08 -06:00
parent a4f0c4bf27
commit 7fde82a94c
6 changed files with 147 additions and 65 deletions
+2 -2
View File
@@ -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,
});
+8 -17
View File
@@ -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.
@@ -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<CreateFlowState>) => 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<void>;
/**
* 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<void>;
};
@@ -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,
+9 -2
View File
@@ -212,8 +212,15 @@ export interface CreateFlowContextValue {
state: CreateFlowState;
currentStep: CreateFlowStep | null;
updateState: (_updates: Partial<CreateFlowState>) => 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;
/**
@@ -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;
}
@@ -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();
});
});