From db34a1f0df938d0f110938b35f5de4560c7d6201 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:18:41 -0600 Subject: [PATCH] Let guests keep a published rule via Save & Exit and a post-finalize sign-in prompt. Email is optional so they can continue without saving; skip from Save & Exit leaves the flow instead of returning to completed. Co-authored-by: Cursor --- app/(app)/create/CreateFlowLayoutClient.tsx | 90 ++++++++++----- .../create/hooks/useCreateFlowFinalize.ts | 33 +++++- app/(app)/create/types.ts | 5 +- app/(app)/create/utils/flowSteps.ts | 21 ++++ app/components/modals/Login/Login.view.tsx | 5 +- app/components/modals/Login/LoginForm.tsx | 74 ++++++++---- app/contexts/AuthModalContext.tsx | 16 ++- docs/create-flow.md | 6 +- lib/create/pendingKeepRuleLogin.ts | 22 ++++ .../create/reviewAndComplete/completed.json | 2 +- messages/en/pages/login.json | 3 + tests/components/Login.test.tsx | 20 ++++ tests/components/LoginForm.test.tsx | 64 +++++++++++ tests/contexts/AuthModalContext.test.tsx | 106 +++++++++++++++++- tests/unit/flowSteps.test.ts | 18 +++ .../unit/hooks/useCreateFlowFinalize.test.tsx | 72 ++++++++++++ tests/unit/pendingKeepRuleLogin.test.ts | 24 ++++ 17 files changed, 521 insertions(+), 60 deletions(-) create mode 100644 lib/create/pendingKeepRuleLogin.ts create mode 100644 tests/unit/pendingKeepRuleLogin.test.ts diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index ae48fcf..9230b57 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -23,8 +23,8 @@ import { CREATE_FLOW_MANAGE_STAKEHOLDERS_VALUE, CREATE_FLOW_REVIEW_RETURN_QUERY_KEY, getNextStep, - getStepIndex, parseReviewReturnSearchParam, + shouldOfferCreateFlowSaveAndExit, createFlowStepUsesSelectSplitScroll, TEMPLATES_FACET_RECOMMEND_QUERY, TEMPLATES_FACET_RECOMMEND_VALUE, @@ -64,6 +64,10 @@ import { } from "../../../lib/create/publishedDocumentToCreateFlowState"; import { METHOD_FACET_API_SECTION_IDS } from "../../../lib/create/customRuleFacets"; import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule"; +import { + clearPendingKeepRuleLogin, + hasPendingKeepRuleLogin, +} from "../../../lib/create/pendingKeepRuleLogin"; import { runCompletedStepExit } from "./utils/runCompletedStepExit"; import messages from "../../../messages/en/index"; import { @@ -91,9 +95,6 @@ import { useCreateFlowDraftSaveBanner, } from "./context/CreateFlowDraftSaveBannerContext"; -/** First step where Save & Exit is offered (first Create Community select per Figma). */ -const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure"); - function CreateFlowSessionShell({ children }: { children: ReactNode }) { const [sessionUser, setSessionUser] = useState< { id: string; email: string } | null | undefined @@ -111,9 +112,9 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) { const sessionResolved = sessionUser !== undefined; // Mirror in-progress draft to localStorage for ALL visitors once we know who - // they are. Refresh-survival is the same UX for guest and signed-in users; - // signed-in users additionally get an explicit "Save & Exit" that PUTs to - // the server (handled in `useCreateFlowExit`). + // they are. Refresh-survival is the same UX for guest and signed-in users. + // Save & Exit: guests open the save-progress login modal; signed-in users + // PUT the server draft (`useCreateFlowExit`). const enableLocalDraftMirroring = sessionResolved; return ( @@ -197,6 +198,8 @@ function CreateFlowLayoutContent({ } | null>(null); const [shareModalOpen, setShareModalOpen] = useState(false); const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false); + const [completedHasPublishedRule, setCompletedHasPublishedRule] = + useState(false); const leaveConfirmResolverRef = useRef<((proceed: boolean) => void) | null>( null, ); @@ -267,14 +270,14 @@ function CreateFlowLayoutContent({ title: completedCopy.guestInvitesSkippedTitle, description: completedCopy.guestInvitesSkippedDescription, }); - return; + } else { + setCompletedFlowBanner({ + key: "guestClaimHint", + status: "warning", + title: completedCopy.guestClaimHintTitle, + description: completedCopy.guestClaimHintDescription, + }); } - setCompletedFlowBanner({ - key: "guestClaimHint", - status: "warning", - title: completedCopy.guestClaimHintTitle, - description: completedCopy.guestClaimHintDescription, - }); }, }); @@ -305,14 +308,26 @@ function CreateFlowLayoutContent({ }); const handleExit = async (opts?: { saveDraft?: boolean }) => { - const saveDraft = opts?.saveDraft ?? false; if (!sessionResolved) return; - // Exit from `/create/completed` is post-publish: the rule is saved, so we - // skip the leave-confirm + login prompt and just wipe the in-flight draft. - // For signed-in users we also DELETE the server draft so a future visit to - // /create starts fresh instead of rehydrating yesterday's work. + // Completed is post-publish. Guests still need Save & Exit → keep-this-rule + // login (the document is live but unclaimed). Signed-in users just leave. if (currentStep === "completed") { + if (sessionUser === null) { + openLogin({ + variant: "keepRule", + nextPath: CREATE_ROUTES.completed, + backdropVariant: "blurredYellow", + onDismiss: () => { + runCompletedStepExit({ + clearState, + clearAnonymousCreateFlowStorage, + router, + }); + }, + }); + return; + } runCompletedStepExit({ clearState, clearAnonymousCreateFlowStorage, @@ -322,7 +337,6 @@ function CreateFlowLayoutContent({ } if (sessionUser === null) { - if (saveDraft) return; const returnToTemplateReview = templateReviewSlug != null ? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1` @@ -428,9 +442,32 @@ function CreateFlowLayoutContent({ useEffect(() => { if (currentStep !== "completed") { setCompletedFlowBanner(null); + setCompletedHasPublishedRule(false); + return; } + setCompletedHasPublishedRule(Boolean(readLastPublishedRule())); }, [currentStep]); + useEffect(() => { + if (currentStep !== "completed") return; + if (!sessionResolved) return; + if (sessionUser !== null) { + clearPendingKeepRuleLogin(); + return; + } + if (!hasPendingKeepRuleLogin()) return; + const timeoutId = window.setTimeout(() => { + if (!hasPendingKeepRuleLogin()) return; + clearPendingKeepRuleLogin(); + openLogin({ + variant: "keepRule", + nextPath: CREATE_ROUTES.completed, + backdropVariant: "blurredYellow", + }); + }, 0); + return () => window.clearTimeout(timeoutId); + }, [currentStep, sessionResolved, sessionUser, openLogin]); + const handleCommunitySaveMagicLinkSubmit = useCallback(async () => { setCommunitySaveMagicLinkError(null); setCommunitySaveMagicLinkSuccess(false); @@ -486,7 +523,6 @@ function CreateFlowLayoutContent({ const isSelectSplitScrollStep = createFlowStepUsesSelectSplitScroll( currentStep, ); - const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1; /** Lockup+card / card-stack: `items-start` + shell `my-auto` so overflow scrolls from the top. */ const mainContentClass = isCompletedStep @@ -506,9 +542,10 @@ function CreateFlowLayoutContent({ ? "max-md:flex-col max-md:items-stretch" : "max-md:flex-col max-md:items-center"; const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`; - const saveDraftOnExit = - Boolean(sessionUser) && - (stepIdx >= SAVE_EXIT_FROM_STEP_INDEX || currentStep === "edit-rule"); + const saveDraftOnExit = shouldOfferCreateFlowSaveAndExit( + currentStep, + sessionUser, + ); const proportionBarProgress = getProportionBarProgressForCreateFlowStep( currentStep, @@ -674,7 +711,10 @@ function CreateFlowLayoutContent({ void }; type OpenLogin = (args: { - variant: "default" | "saveProgress"; + variant: "default" | "saveProgress" | "keepRule"; nextPath: string; backdropVariant: "blurredYellow"; }) => void; @@ -79,6 +80,21 @@ export function useCreateFlowFinalize({ ? state.editingPublishedRuleId.trim() : ""; + if (editingId.length > 0 && sessionUser === null) { + setIsPublishing(false); + writeLastPublishedRule({ + id: editingId, + title, + summary: summary ?? null, + document: ruleDocument, + }); + updateState({ editingPublishedRuleId: undefined }); + writePendingKeepRuleLogin(); + onGuestPublished?.({ skippedInvites: false }); + router.push(createFlowStepPath("completed")); + return; + } + if (editingId.length > 0) { const updateResult = await updatePublishedRule(editingId, { title, @@ -99,8 +115,11 @@ export function useCreateFlowFinalize({ } if (updateResult.status === 401) { openLogin({ - variant: "default", - nextPath: loginReturnPath, + variant: sessionUser === null ? "keepRule" : "default", + nextPath: + sessionUser === null + ? createFlowStepPath("completed") + : loginReturnPath, backdropVariant: "blurredYellow", }); return; @@ -135,6 +154,7 @@ export function useCreateFlowFinalize({ document: ruleDocument, }); if (isGuest) { + writePendingKeepRuleLogin(); onGuestPublished?.({ skippedInvites: skippedGuestInvites }); } router.push( @@ -147,8 +167,11 @@ export function useCreateFlowFinalize({ } if (publishResult.status === 401) { openLogin({ - variant: "default", - nextPath: loginReturnPath, + variant: sessionUser === null ? "keepRule" : "default", + nextPath: + sessionUser === null + ? createFlowStepPath("completed") + : loginReturnPath, backdropVariant: "blurredYellow", }); return; diff --git a/app/(app)/create/types.ts b/app/(app)/create/types.ts index 69a9fbe..56f4798 100644 --- a/app/(app)/create/types.ts +++ b/app/(app)/create/types.ts @@ -279,9 +279,8 @@ export interface CreateFlowContextValue { * * Current consumer: {@link SignedInDraftHydration} — when a signed-in user * has already started editing, we skip replaying their server draft on top - * of in-progress local state. Save & Exit visibility is driven by step - * index (`SAVE_EXIT_FROM_STEP_INDEX` in `CreateFlowLayoutClient`), not this - * flag. + * of in-progress local state. Save & Exit visibility is + * `shouldOfferCreateFlowSaveAndExit` in `utils/flowSteps.ts`, not this flag. */ interactionTouched: boolean; markCreateFlowInteraction: () => void; diff --git a/app/(app)/create/utils/flowSteps.ts b/app/(app)/create/utils/flowSteps.ts index 10cbab9..2251e1d 100644 --- a/app/(app)/create/utils/flowSteps.ts +++ b/app/(app)/create/utils/flowSteps.ts @@ -117,6 +117,27 @@ export function getStepIndex(step: CreateFlowStep | null | undefined): number { return FLOW_STEP_ORDER.indexOf(step); } +/** First wizard step that offers Save & Exit (first Create Community select). */ +const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("community-structure"); + +/** + * Top-nav Save & Exit (vs Exit). Guests and signed-in users share this from + * `community-structure` onward and on `edit-rule`. On completed, guests still + * get Save & Exit (claim/login); signed-in users get Exit (already owned). + * + * @param sessionUser `null` is a guest. `undefined` (session in flight) is + * not a guest — completed stays Exit until the session resolves. + */ +export function shouldOfferCreateFlowSaveAndExit( + currentStep: CreateFlowStep | null | undefined, + sessionUser?: { id: string } | null, +): boolean { + if (currentStep == null) return false; + if (currentStep === "completed") return sessionUser === null; + if (currentStep === "edit-rule") return true; + return getStepIndex(currentStep) >= SAVE_EXIT_FROM_STEP_INDEX; +} + /** * Steps where below `lg` the main column scrolls with split layout * (`CreateFlowLayoutClient` — Linear CR-92 §4). diff --git a/app/components/modals/Login/Login.view.tsx b/app/components/modals/Login/Login.view.tsx index 3796dae..39240a8 100644 --- a/app/components/modals/Login/Login.view.tsx +++ b/app/components/modals/Login/Login.view.tsx @@ -31,7 +31,10 @@ export function LoginView({
{ + if (event.target !== event.currentTarget) return; + onClose(); + }} role="presentation" >
string, +): { title: string; description: string } { + if (sent) { + return { title: t("successTitle"), description: t("successBody") }; + } + if (variant === "saveProgress") { + return { + title: t("saveProgressTitle"), + description: t("saveProgressSubtitle"), + }; + } + if (variant === "keepRule") { + return { + title: t("keepRuleTitle"), + description: t("keepRuleSubtitle"), + }; + } + return { title: t("title"), description: t("subtitle") }; +} + +function shouldAttachCreateFlowDraft( + variant: LoginFormVariant, + nextPath: string, +): boolean { + if (variant === "keepRule") return false; + return variant === "saveProgress" || nextPath.includes("syncDraft=1"); +} export type LoginFormProps = { variant?: LoginFormVariant; /** Overrides URL `next` for `requestMagicLink` (e.g. create-flow exit modal). */ magicLinkNextPath?: string; + /** `keepRule`: Continue without saving. */ + onDismiss?: () => void; }; export default function LoginForm({ variant = "default", magicLinkNextPath, + onDismiss, }: LoginFormProps) { const t = useTranslation("pages.login"); const tFooter = useTranslation("footer"); @@ -72,7 +106,7 @@ export default function LoginForm({ const nextParam = searchParams.get("next"); const errorParam = searchParams.get("error"); - const isSaveProgress = variant === "saveProgress"; + const heading = loginFormHeading(variant, sent, t); /** Drop `error` from the URL so URL-driven messages don’t linger after a new attempt. */ const stripErrorQuery = useCallback(() => { @@ -96,8 +130,7 @@ export default function LoginForm({ try { const rawNext = magicLinkNextPath ?? nextParam; const nextPath = safeInternalPath(rawNext); - const shouldAttachDraft = - isSaveProgress || nextPath.includes("syncDraft=1"); + const shouldAttachDraft = shouldAttachCreateFlowDraft(variant, nextPath); const localDraft = readAnonymousCreateFlowState(); const draft = shouldAttachDraft && Object.keys(localDraft).length > 0 @@ -115,7 +148,7 @@ export default function LoginForm({ } return; } - if (isSaveProgress || nextPath.includes("syncDraft=1")) { + if (shouldAttachDraft) { setTransferPendingFlag(); } setEmail(trimmed); @@ -127,11 +160,11 @@ export default function LoginForm({ } }, [ email, - isSaveProgress, magicLinkNextPath, nextParam, stripErrorQuery, t, + variant, ]); const urlErrorMessage = @@ -155,20 +188,8 @@ export default function LoginForm({
@@ -255,6 +276,19 @@ export default function LoginForm({ > {t("sendMagicLink")} + {onDismiss ? ( + + ) : null}

{t("legalPrefix")} void; }; type AuthModalContextValue = { @@ -49,6 +54,14 @@ export function AuthModalProvider({ children }: { children: ReactNode }) { ); const backdropVariant = opts.backdropVariant ?? "blurredYellow"; + const keepRuleDismiss = + opts.variant === "keepRule" + ? () => { + const extra = opts.onDismiss; + closeLogin(); + extra?.(); + } + : undefined; return ( @@ -63,6 +76,7 @@ export function AuthModalProvider({ children }: { children: ReactNode }) { diff --git a/docs/create-flow.md b/docs/create-flow.md index d75cdd5..a5498bf 100644 --- a/docs/create-flow.md +++ b/docs/create-flow.md @@ -61,7 +61,7 @@ The step persists **`stakeholderEmails`** on `CreateFlowState` (validated on `PU ### Fresh start vs continue draft (signed-in + sync) -**Established pattern:** anonymous and signed-in users should see the **same** wizard when starting a **new** rule from marketing or profile: empty state at the first step, with no surprise reload of old work. Both can **publish** (guests get an unlisted public `/rules/{id}` with no owner). Signed-in users additionally get **Save & Exit**; their in-progress payload may also live on **`/api/drafts/me`** when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`. +**Established pattern:** anonymous and signed-in users should see the **same** wizard when starting a **new** rule from marketing or profile: empty state at the first step, with no surprise reload of old work. Both can **publish** (guests get an unlisted public `/rules/{id}` with no owner). Both see **Save & Exit** from `community-structure` onward (and `edit-rule`). On **`/create/completed`**, guests still get **Save & Exit** (keep-this-rule magic-link modal); signed-in users get **Exit**. Wizard guests open the save-progress magic-link modal; signed-in users may **PUT** `/api/drafts/me` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`. - **New rule entry** (always a clean slate): call [`prepareFreshCreateFlowEntry`](../app/(app)/create/utils/prepareFreshCreateFlowEntry.ts) **before** `router.push` into `/create` or `/create/review-template/...`. It clears **`create-flow-anonymous`** and the core-value-details `localStorage` key; when sync is on, it **`DELETE`s `/api/drafts/me`** so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) does not rehydrate a stale server draft after local storage was wiped. - **Continue saved draft** (profile): do **not** call `prepareFreshCreateFlowEntry`. Clear the same `localStorage` keys **only** (see [`ProfilePageClient`](../app/(app)/profile/ProfilePageClient.tsx) `handleContinueDraft`) so the client mirror is empty, then navigate to **`/create/{savedStep}`**. Hydration loads the server draft; the URL may be corrected to `currentStep` when it differs from the path. @@ -105,8 +105,8 @@ Only one `?fromFlow=1` marker exists, on one hop (`/create/review` → `/templat | Mode | Where progress lives | Save & Exit / publish | | --- | --- | --- | -| **Anonymous** | `localStorage` key **`create-flow-anonymous`** | **Exit** opens save-progress magic link; after verify, optional **PUT** `/api/drafts/me` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` (see Tickets 4–5 in [guides/backend-linear-tickets.md](guides/backend-linear-tickets.md)). **Finalize** `POST`s `/api/rules` without a session (`userId` null) and sets httpOnly **`cr_rule_claim`**. The public URL works; the row is omitted from `GET /api/rules` until claimed. Signing in on the same browser attaches `userId` (profile, edit, invites). | -| **Signed-in** | In-memory React state in **`CreateFlowContext`** | **Save & Exit** from the **`community-structure`** step onward (step index ≥ `community-structure`) may **PUT** `/api/drafts/me` when sync is on. **Finalize** stores the rule with **`userId`**. **Sign out** is on profile, not in the create top nav. | +| **Anonymous** | `localStorage` key **`create-flow-anonymous`** | **Save & Exit** (from `community-structure` onward, plus `edit-rule`) opens the save-progress magic-link modal; after verify, optional **PUT** `/api/drafts/me` when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` (see Tickets 4–5 in [guides/backend-linear-tickets.md](guides/backend-linear-tickets.md)). **Finalize** `POST`s `/api/rules` without a session (`userId` null) and sets httpOnly **`cr_rule_claim`**. Landing on **`/create/completed`** opens a **keep-this-rule** magic-link modal (no draft transfer); the top nav stays **Save & Exit** so they can reopen it (**Continue without saving** from that control leaves the flow). After Finalize, skip on the auto-opened modal stays on completed. **Edit** shows when a last-published rule is in session storage. The public URL works; the row is omitted from `GET /api/rules` until claimed. Signing in on the same browser attaches `userId` (profile, edit, invites). | +| **Signed-in** | In-memory React state in **`CreateFlowContext`** | **Save & Exit** from the **`community-structure`** step onward (and `edit-rule`) may **PUT** `/api/drafts/me` when sync is on. **Completed** is **Exit**. **Finalize** stores the rule with **`userId`**. **Sign out** is on profile, not in the create top nav. | Details and edge cases (conflict confirm, banners, `?syncDraft=1`) match **Ticket 4**, **Ticket 5**, and [`docs/guides/backend-roadmap.md`](guides/backend-roadmap.md) §12. diff --git a/lib/create/pendingKeepRuleLogin.ts b/lib/create/pendingKeepRuleLogin.ts new file mode 100644 index 0000000..145461d --- /dev/null +++ b/lib/create/pendingKeepRuleLogin.ts @@ -0,0 +1,22 @@ +/** + * Guest finalize → `/create/completed` keep-this-rule login. Stored in + * sessionStorage so the prompt survives create-layout remounts and is not + * tied to the Finalize click (that click would otherwise dismiss the overlay). + */ +export const CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY = + "createFlow.pendingKeepRuleLogin"; + +export function writePendingKeepRuleLogin(): void { + if (typeof sessionStorage === "undefined") return; + sessionStorage.setItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY, "1"); +} + +export function hasPendingKeepRuleLogin(): boolean { + if (typeof sessionStorage === "undefined") return false; + return sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY) === "1"; +} + +export function clearPendingKeepRuleLogin(): void { + if (typeof sessionStorage === "undefined") return; + sessionStorage.removeItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY); +} diff --git a/messages/en/create/reviewAndComplete/completed.json b/messages/en/create/reviewAndComplete/completed.json index 7c18017..51c7274 100644 --- a/messages/en/create/reviewAndComplete/completed.json +++ b/messages/en/create/reviewAndComplete/completed.json @@ -18,5 +18,5 @@ "guestInvitesSkippedTitle": "Stakeholder invites were not sent", "guestInvitesSkippedDescription": "Sign in to invite people by email. Until then, share the public link to this CommunityRule.", "guestClaimHintTitle": "Sign in to keep this rule on your profile", - "guestClaimHintDescription": "Use Log in on this same browser to attach the rule to your account so you can edit it later." + "guestClaimHintDescription": "Sign in on this same browser to attach the rule to your account so you can edit it later." } diff --git a/messages/en/pages/login.json b/messages/en/pages/login.json index 7391ab9..9fb560d 100644 --- a/messages/en/pages/login.json +++ b/messages/en/pages/login.json @@ -3,6 +3,9 @@ "subtitle": "Enter your email and we'll send you a magic link to sign in. No password needed!", "saveProgressTitle": "Save your progress?", "saveProgressSubtitle": "We need your email to save, and we'll send you a magic link to sign in. If you don't save now you could lose your progress.", + "keepRuleTitle": "Sign in to keep this rule on your profile", + "keepRuleSubtitle": "Enter your email and we'll send a magic link. Open it on this same browser to attach the rule to your account. You can also continue without saving.", + "continueWithoutSaving": "Continue without saving", "emailLabel": "Email address", "emailPlaceholder": "you@example.com", "sendMagicLink": "Send me a magic link", diff --git a/tests/components/Login.test.tsx b/tests/components/Login.test.tsx index abcea52..4683b09 100644 --- a/tests/components/Login.test.tsx +++ b/tests/components/Login.test.tsx @@ -110,6 +110,26 @@ describe("Login", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it("closes on backdrop pointerdown, not a leftover click", async () => { + const onClose = vi.fn(); + renderWithProviders( + +

Body

+ , + ); + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + const backdrop = screen.getByRole("dialog").parentElement; + expect(backdrop).not.toBeNull(); + fireEvent.click(backdrop!); + expect(onClose).not.toHaveBeenCalled(); + fireEvent.pointerDown(screen.getByRole("dialog")); + expect(onClose).not.toHaveBeenCalled(); + fireEvent.pointerDown(backdrop!); + expect(onClose).toHaveBeenCalledTimes(1); + }); + it("locks body scroll while open", async () => { renderWithProviders( diff --git a/tests/components/LoginForm.test.tsx b/tests/components/LoginForm.test.tsx index 8a74e5b..f94d757 100644 --- a/tests/components/LoginForm.test.tsx +++ b/tests/components/LoginForm.test.tsx @@ -174,6 +174,70 @@ describe("LoginForm", () => { expect(setTransferPendingFlag).toHaveBeenCalled(); }); + it("keepRule variant claims without attaching a create-flow draft", async () => { + const user = userEvent.setup(); + window.localStorage.setItem( + "create-flow-anonymous", + JSON.stringify({ title: "Guest draft" }), + ); + vi.mocked(requestMagicLink).mockResolvedValue({ ok: true }); + renderWithProviders( + + + , + ); + expect( + screen.getByRole("heading", { + name: /sign in to keep this rule on your profile/i, + }), + ).toBeInTheDocument(); + await user.type( + screen.getByRole("textbox", { name: /email address/i }), + "guest@example.com", + ); + await user.click( + screen.getByRole("button", { name: /send me a magic link/i }), + ); + await waitFor(() => { + expect(requestMagicLink).toHaveBeenCalledWith( + "guest@example.com", + "/create/completed", + undefined, + ); + }); + expect(setTransferPendingFlag).not.toHaveBeenCalled(); + window.localStorage.removeItem("create-flow-anonymous"); + }); + + it("keepRule continue without saving calls onDismiss", async () => { + const user = userEvent.setup(); + const onDismiss = vi.fn(); + renderWithProviders( + + + , + ); + await user.click( + screen.getByRole("button", { name: /continue without saving/i }), + ); + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(requestMagicLink).not.toHaveBeenCalled(); + }); + + it("default variant has no continue without saving action", () => { + renderLoginForm(); + expect( + screen.queryByRole("button", { name: /continue without saving/i }), + ).not.toBeInTheDocument(); + }); + it("passes safe next path when next query param is set", async () => { const user = userEvent.setup(); navMock.searchParams = new URLSearchParams("next=/learn"); diff --git a/tests/contexts/AuthModalContext.test.tsx b/tests/contexts/AuthModalContext.test.tsx index c17bb78..fe67d4f 100644 --- a/tests/contexts/AuthModalContext.test.tsx +++ b/tests/contexts/AuthModalContext.test.tsx @@ -1,4 +1,4 @@ -import { Suspense } from "react"; +import { Suspense, useState } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -47,6 +47,7 @@ import { setTransferPendingFlag } from "../../app/(app)/create/utils/anonymousDr function LoginTrigger() { const { openLogin, closeLogin } = useAuthModal(); + const [leftCompleted, setLeftCompleted] = useState(false); return (
+ + + {leftCompleted ?

Left completed

: null}
); } @@ -133,6 +158,9 @@ describe("AuthModalProvider (header overlay)", () => { await waitFor(() => { expect(screen.getByRole("dialog")).toBeInTheDocument(); }); + expect( + screen.queryByRole("button", { name: /continue without saving/i }), + ).not.toBeInTheDocument(); await user.type( screen.getByRole("textbox", { name: /email address/i }), "guest@example.com", @@ -149,4 +177,80 @@ describe("AuthModalProvider (header overlay)", () => { }); expect(setTransferPendingFlag).toHaveBeenCalled(); }); + + it("keepRule openLogin does not set transfer pending", async () => { + const user = userEvent.setup(); + vi.mocked(requestMagicLink).mockResolvedValue({ ok: true }); + renderWithProviders( + + + , + ); + await user.click(screen.getByRole("button", { name: /^open keep rule$/i })); + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + expect( + screen.getByRole("heading", { + name: /sign in to keep this rule on your profile/i, + }), + ).toBeInTheDocument(); + await user.type( + screen.getByRole("textbox", { name: /email address/i }), + "guest@example.com", + ); + await user.click( + screen.getByRole("button", { name: /send me a magic link/i }), + ); + await waitFor(() => { + expect(requestMagicLink).toHaveBeenCalledWith( + "guest@example.com", + "/create/completed", + undefined, + ); + }); + expect(setTransferPendingFlag).not.toHaveBeenCalled(); + }); + + it("keepRule continue without saving closes the overlay", async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + await user.click(screen.getByRole("button", { name: /^open keep rule$/i })); + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + await user.click( + screen.getByRole("button", { name: /continue without saving/i }), + ); + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + expect(screen.queryByText("Left completed")).not.toBeInTheDocument(); + }); + + it("keepRule onDismiss runs when continue without saving is pressed", async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + await user.click( + screen.getByRole("button", { name: /open keep rule then leave/i }), + ); + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + await user.click( + screen.getByRole("button", { name: /continue without saving/i }), + ); + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + expect(screen.getByText("Left completed")).toBeInTheDocument(); + }); }); diff --git a/tests/unit/flowSteps.test.ts b/tests/unit/flowSteps.test.ts index 7a8b294..ee55315 100644 --- a/tests/unit/flowSteps.test.ts +++ b/tests/unit/flowSteps.test.ts @@ -8,6 +8,7 @@ import { getStepIndex, parseReviewReturnSearchParam, resolveCreateFlowBackTarget, + shouldOfferCreateFlowSaveAndExit, TEMPLATES_FACET_RECOMMEND_QUERY, TEMPLATES_FACET_RECOMMEND_VALUE, TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY, @@ -132,4 +133,21 @@ describe("flowSteps", () => { ).toBeNull(); expect(parseReviewReturnSearchParam(null)).toBeNull(); }); + + it("offers Save & Exit from community-structure through final-review and on edit-rule", () => { + expect(shouldOfferCreateFlowSaveAndExit("informational")).toBe(false); + expect(shouldOfferCreateFlowSaveAndExit("community-name")).toBe(false); + expect(shouldOfferCreateFlowSaveAndExit("community-structure")).toBe(true); + expect(shouldOfferCreateFlowSaveAndExit("final-review")).toBe(true); + expect(shouldOfferCreateFlowSaveAndExit("edit-rule")).toBe(true); + }); + + it("offers Save & Exit on completed only for guests", () => { + expect(shouldOfferCreateFlowSaveAndExit("completed")).toBe(false); + expect( + shouldOfferCreateFlowSaveAndExit("completed", { id: "user-1" }), + ).toBe(false); + expect(shouldOfferCreateFlowSaveAndExit("completed", null)).toBe(true); + expect(shouldOfferCreateFlowSaveAndExit(null)).toBe(false); + }); }); diff --git a/tests/unit/hooks/useCreateFlowFinalize.test.tsx b/tests/unit/hooks/useCreateFlowFinalize.test.tsx index 2ff6323..965d505 100644 --- a/tests/unit/hooks/useCreateFlowFinalize.test.tsx +++ b/tests/unit/hooks/useCreateFlowFinalize.test.tsx @@ -4,6 +4,7 @@ import type { CreateFlowState } from "../../../app/(app)/create/types"; import { useCreateFlowFinalize } from "../../../app/(app)/create/hooks/useCreateFlowFinalize"; import { publishRule, updatePublishedRule } from "../../../lib/create/api"; import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule"; +import { CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY } from "../../../lib/create/pendingKeepRuleLogin"; import { CREATE_FLOW_COMPLETED_CELEBRATE_QUERY, CREATE_FLOW_COMPLETED_CELEBRATE_VALUE, @@ -44,6 +45,7 @@ describe("useCreateFlowFinalize", () => { updateState.mockReset(); openLogin.mockReset(); onGuestPublished.mockReset(); + sessionStorage.clear(); }); afterEach(() => { @@ -82,6 +84,9 @@ describe("useCreateFlowFinalize", () => { summary: "Published summary", document: {}, }); + expect( + sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY), + ).toBeNull(); }); it("does not publish while the session is unresolved", async () => { @@ -137,6 +142,7 @@ describe("useCreateFlowFinalize", () => { }); expect(onGuestPublished).toHaveBeenCalledWith({ skippedInvites: true }); expect(openLogin).not.toHaveBeenCalled(); + expect(sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY)).toBe("1"); expect(router.push).toHaveBeenCalledWith( `/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`, ); @@ -172,6 +178,7 @@ describe("useCreateFlowFinalize", () => { }); expect(onGuestPublished).toHaveBeenCalledWith({ skippedInvites: false }); expect(openLogin).not.toHaveBeenCalled(); + expect(sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY)).toBe("1"); expect(router.push).toHaveBeenCalledWith( `/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`, ); @@ -250,4 +257,69 @@ describe("useCreateFlowFinalize", () => { editingPublishedRuleId: undefined, }); }); + + it("does not PATCH when a guest finalizes an already-published rule", async () => { + const { result } = renderHook(() => + useCreateFlowFinalize({ + state: { + ...emptyState, + editingPublishedRuleId: "guest-rule-1", + }, + router, + openLogin, + updateState, + loginReturnPath: "/create/final-review?syncDraft=1", + sessionUser: null, + onGuestPublished, + }), + ); + + await act(async () => { + await result.current.finalize(); + }); + + expect(updatePublishedRule).not.toHaveBeenCalled(); + expect(publishRule).not.toHaveBeenCalled(); + expect(openLogin).not.toHaveBeenCalled(); + expect(onGuestPublished).toHaveBeenCalledWith({ skippedInvites: false }); + expect(writeLastPublishedRule).toHaveBeenCalledWith({ + id: "guest-rule-1", + title: "Published title", + summary: "Published summary", + document: {}, + }); + expect(sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY)).toBe("1"); + expect(router.push).toHaveBeenCalledWith("/create/completed"); + }); + + it("opens keepRule login when a guest publish is unauthorized", async () => { + vi.mocked(publishRule).mockResolvedValue({ + ok: false, + error: "Unauthorized", + status: 401, + }); + + const { result } = renderHook(() => + useCreateFlowFinalize({ + state: emptyState, + router, + openLogin, + updateState, + loginReturnPath: "/create/final-review?syncDraft=1", + sessionUser: null, + onGuestPublished, + }), + ); + + await act(async () => { + await result.current.finalize(); + }); + + expect(openLogin).toHaveBeenCalledWith({ + variant: "keepRule", + nextPath: "/create/completed", + backdropVariant: "blurredYellow", + }); + expect(router.push).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/pendingKeepRuleLogin.test.ts b/tests/unit/pendingKeepRuleLogin.test.ts new file mode 100644 index 0000000..c8ab74d --- /dev/null +++ b/tests/unit/pendingKeepRuleLogin.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY, + clearPendingKeepRuleLogin, + hasPendingKeepRuleLogin, + writePendingKeepRuleLogin, +} from "../../lib/create/pendingKeepRuleLogin"; + +describe("pendingKeepRuleLogin", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it("writes, reads, and clears the session flag", () => { + expect(hasPendingKeepRuleLogin()).toBe(false); + writePendingKeepRuleLogin(); + expect(sessionStorage.getItem(CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY)).toBe( + "1", + ); + expect(hasPendingKeepRuleLogin()).toBe(true); + clearPendingKeepRuleLogin(); + expect(hasPendingKeepRuleLogin()).toBe(false); + }); +});