Fix draft error when logged out

This commit is contained in:
adilallo
2026-05-26 09:09:11 -06:00
parent 8420ce42e3
commit 62efb6a0cc
8 changed files with 203 additions and 95 deletions
@@ -740,24 +740,24 @@ export function CommunicationMethodsScreen() {
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS} className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady} aria-busy={!recommendationsReady}
> >
{recommendationsReady ? ( {recommendationsReady && (
<CardStack <CardStack
cards={sampleCards} cards={sampleCards}
selectedIds={selectedIds} selectedIds={selectedIds}
onCardSelect={handleCardClick} onCardSelect={handleCardClick}
expanded={expanded} expanded={expanded}
onToggleExpand={() => { onToggleExpand={() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setExpanded((prev) => !prev); setExpanded((prev) => !prev);
}} }}
hasMore={true} hasMore={true}
toggleLabel={comm.page.seeAllLink} toggleLabel={comm.page.seeAllLink}
compactRecommendedLimit={5} compactRecommendedLimit={5}
compactCardIds={compactCardIds} compactCardIds={compactCardIds}
compactDesktopLayout="flexWrap" compactDesktopLayout="flexWrap"
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
) : null} )}
</div> </div>
</div> </div>
@@ -739,24 +739,24 @@ export function ConflictManagementScreen() {
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS} className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady} aria-busy={!recommendationsReady}
> >
{recommendationsReady ? ( {recommendationsReady && (
<CardStack <CardStack
cards={sampleCards} cards={sampleCards}
selectedIds={selectedIds} selectedIds={selectedIds}
onCardSelect={handleCardClick} onCardSelect={handleCardClick}
expanded={expanded} expanded={expanded}
onToggleExpand={() => { onToggleExpand={() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setExpanded((prev) => !prev); setExpanded((prev) => !prev);
}} }}
hasMore={true} hasMore={true}
toggleLabel={cm.page.seeAllLink} toggleLabel={cm.page.seeAllLink}
compactRecommendedLimit={5} compactRecommendedLimit={5}
compactCardIds={compactCardIds} compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive" compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
) : null} )}
</div> </div>
</div> </div>
@@ -732,24 +732,24 @@ export function MembershipMethodsScreen() {
className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS} className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}
aria-busy={!recommendationsReady} aria-busy={!recommendationsReady}
> >
{recommendationsReady ? ( {recommendationsReady && (
<CardStack <CardStack
cards={sampleCards} cards={sampleCards}
selectedIds={selectedIds} selectedIds={selectedIds}
onCardSelect={handleCardClick} onCardSelect={handleCardClick}
expanded={expanded} expanded={expanded}
onToggleExpand={() => { onToggleExpand={() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setExpanded((prev) => !prev); setExpanded((prev) => !prev);
}} }}
hasMore={true} hasMore={true}
toggleLabel={mem.page.seeAllLink} toggleLabel={mem.page.seeAllLink}
compactRecommendedLimit={5} compactRecommendedLimit={5}
compactCardIds={compactCardIds} compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive" compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
) : null} )}
</div> </div>
</div> </div>
@@ -766,37 +766,37 @@ export function DecisionApproachesScreen() {
className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0" className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0"
aria-busy={!recommendationsReady} aria-busy={!recommendationsReady}
> >
{recommendationsReady ? ( {recommendationsReady && (
<CardStack <CardStack
cards={sampleCards} cards={sampleCards}
selectedIds={selectedIds} selectedIds={selectedIds}
onCardSelect={handleCardSelect} onCardSelect={handleCardSelect}
expanded={expanded} expanded={expanded}
onToggleExpand={handleToggleExpand} onToggleExpand={handleToggleExpand}
hasMore={true} hasMore={true}
toggleLabel={da.cardStack.toggleSeeAll} toggleLabel={da.cardStack.toggleSeeAll}
showLessLabel={da.cardStack.toggleShowLess} showLessLabel={da.cardStack.toggleShowLess}
title="" title=""
description={ description={
expanded ? ( expanded ? (
<> <>
{da.cardStack.expandedStackDescriptionBefore} {da.cardStack.expandedStackDescriptionBefore}
<InlineTextButton onClick={handleOpenAddWizard}> <InlineTextButton onClick={handleOpenAddWizard}>
{da.sidebar.descriptionLinkLabel} {da.sidebar.descriptionLinkLabel}
</InlineTextButton> </InlineTextButton>
{da.cardStack.expandedStackDescriptionAfter} {da.cardStack.expandedStackDescriptionAfter}
</> </>
) : ( ) : (
"" ""
) )
} }
layout="singleStack" layout="singleStack"
compactRecommendedLimit={5} compactRecommendedLimit={5}
compactCardIds={compactCardIds} compactCardIds={compactCardIds}
className="w-full" className="w-full"
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
) : null} )}
</div> </div>
<Create <Create
@@ -11,6 +11,14 @@ import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled"
*/ */
export const FRESH_ENTRY_PENDING_KEY = "create:fresh-entry-pending"; export const FRESH_ENTRY_PENDING_KEY = "create:fresh-entry-pending";
export type PrepareFreshCreateFlowEntryOptions = {
/**
* When `true`, and backend sync is on, also `DELETE /api/drafts/me`.
* Omit or pass `false` for guests — they have no server draft to clear.
*/
signedIn?: boolean;
};
export function hasFreshEntryPending(): boolean { export function hasFreshEntryPending(): boolean {
if (typeof window === "undefined") return false; if (typeof window === "undefined") return false;
try { try {
@@ -38,10 +46,17 @@ function clearFreshEntryPending(): void {
} }
} }
function clearServerDraftWhenSignedIn(signedIn: boolean): void {
if (!signedIn || !isBackendSyncEnabled()) return;
setFreshEntryPending();
void deleteServerDraft().finally(clearFreshEntryPending);
}
/** /**
* Call **before** navigating into `/create` from marketing or profile “new rule” * Call **before** navigating into `/create` from marketing or profile “new rule”
* entry points so signed-in + sync matches an anonymous fresh start: wipe * entry points so signed-in + sync matches an anonymous fresh start: wipe
* `localStorage` draft keys and, when sync is on, `DELETE /api/drafts/me`. * `localStorage` draft keys and, when sync is on and the user is signed in,
* `DELETE /api/drafts/me`.
* *
* Synchronous variant: returns immediately after clearing local state and * Synchronous variant: returns immediately after clearing local state and
* scheduling the server draft delete in the background. Sets a sessionStorage * scheduling the server draft delete in the background. Sets a sessionStorage
@@ -50,12 +65,13 @@ function clearFreshEntryPending(): void {
* *
* Do **not** use for “Continue draft” — that path should load the server draft. * Do **not** use for “Continue draft” — that path should load the server draft.
*/ */
export function prepareFreshCreateFlowEntrySync(): void { export function prepareFreshCreateFlowEntrySync(
options: PrepareFreshCreateFlowEntryOptions = {},
): void {
const signedIn = options.signedIn === true;
clearAnonymousCreateFlowStorage(); clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage(); clearCoreValueDetailsLocalStorage();
if (!isBackendSyncEnabled()) return; clearServerDraftWhenSignedIn(signedIn);
setFreshEntryPending();
void deleteServerDraft().finally(clearFreshEntryPending);
} }
/** /**
@@ -63,10 +79,13 @@ export function prepareFreshCreateFlowEntrySync(): void {
* before continuing (e.g. tests, programmatic reset flows). Most click handlers * before continuing (e.g. tests, programmatic reset flows). Most click handlers
* should use {@link prepareFreshCreateFlowEntrySync} for instant navigation. * should use {@link prepareFreshCreateFlowEntrySync} for instant navigation.
*/ */
export async function prepareFreshCreateFlowEntry(): Promise<void> { export async function prepareFreshCreateFlowEntry(
options: PrepareFreshCreateFlowEntryOptions = {},
): Promise<void> {
const signedIn = options.signedIn === true;
clearAnonymousCreateFlowStorage(); clearAnonymousCreateFlowStorage();
clearCoreValueDetailsLocalStorage(); clearCoreValueDetailsLocalStorage();
if (!isBackendSyncEnabled()) return; if (!signedIn || !isBackendSyncEnabled()) return;
setFreshEntryPending(); setFreshEntryPending();
try { try {
await deleteServerDraft(); await deleteServerDraft();
+1 -1
View File
@@ -253,7 +253,7 @@ export default function ProfilePageClient() {
}, [draft, router]); }, [draft, router]);
const handleStartNewCustomRule = useCallback(() => { const handleStartNewCustomRule = useCallback(() => {
prepareFreshCreateFlowEntrySync(); prepareFreshCreateFlowEntrySync({ signedIn: true });
router.push("/create/informational"); router.push("/create/informational");
}, [router]); }, [router]);
@@ -57,9 +57,9 @@ const TopContainer = memo<TopProps>(
* (see {@link prepareFreshCreateFlowEntrySync}). * (see {@link prepareFreshCreateFlowEntrySync}).
*/ */
const handleCreateRuleClick = useCallback(() => { const handleCreateRuleClick = useCallback(() => {
prepareFreshCreateFlowEntrySync(); prepareFreshCreateFlowEntrySync({ signedIn: loggedIn });
router.push("/create/informational"); router.push("/create/informational");
}, [router]); }, [loggedIn, router]);
// Schema markup for site navigation // Schema markup for site navigation
const schemaData = { const schemaData = {
@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
FRESH_ENTRY_PENDING_KEY,
hasFreshEntryPending,
prepareFreshCreateFlowEntry,
prepareFreshCreateFlowEntrySync,
} from "../../app/(app)/create/utils/prepareFreshCreateFlowEntry";
import { CREATE_FLOW_ANONYMOUS_KEY } from "../../app/(app)/create/utils/anonymousDraftStorage";
const deleteServerDraft = vi.fn();
const isBackendSyncEnabled = vi.fn();
vi.mock("../../lib/create/api", () => ({
deleteServerDraft: (...args: unknown[]) => deleteServerDraft(...args),
}));
vi.mock("../../lib/create/backendSyncEnabled", () => ({
isBackendSyncEnabled: () => isBackendSyncEnabled(),
}));
describe("prepareFreshCreateFlowEntrySync", () => {
beforeEach(() => {
deleteServerDraft.mockReset();
deleteServerDraft.mockResolvedValue(undefined);
isBackendSyncEnabled.mockReturnValue(true);
window.localStorage.clear();
window.sessionStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
});
it("clears local draft storage for guests without calling deleteServerDraft", () => {
window.localStorage.setItem(
CREATE_FLOW_ANONYMOUS_KEY,
JSON.stringify({ title: "Stale" }),
);
prepareFreshCreateFlowEntrySync();
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
expect(deleteServerDraft).not.toHaveBeenCalled();
expect(hasFreshEntryPending()).toBe(false);
});
it("clears local draft storage and deletes the server draft when signed in", async () => {
prepareFreshCreateFlowEntrySync({ signedIn: true });
expect(deleteServerDraft).toHaveBeenCalledTimes(1);
expect(window.sessionStorage.getItem(FRESH_ENTRY_PENDING_KEY)).toBe("1");
await vi.waitFor(() => {
expect(hasFreshEntryPending()).toBe(false);
});
});
it("skips server draft delete when backend sync is disabled", () => {
isBackendSyncEnabled.mockReturnValue(false);
prepareFreshCreateFlowEntrySync({ signedIn: true });
expect(deleteServerDraft).not.toHaveBeenCalled();
expect(hasFreshEntryPending()).toBe(false);
});
});
describe("prepareFreshCreateFlowEntry", () => {
beforeEach(() => {
deleteServerDraft.mockReset();
deleteServerDraft.mockResolvedValue(undefined);
isBackendSyncEnabled.mockReturnValue(true);
window.sessionStorage.clear();
});
afterEach(() => {
window.sessionStorage.clear();
});
it("awaits deleteServerDraft only when signed in", async () => {
await prepareFreshCreateFlowEntry();
expect(deleteServerDraft).not.toHaveBeenCalled();
await prepareFreshCreateFlowEntry({ signedIn: true });
expect(deleteServerDraft).toHaveBeenCalledTimes(1);
expect(hasFreshEntryPending()).toBe(false);
});
});