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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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(
|
||||
<Login isOpen onClose={onClose} ariaLabelledBy="login-modal-heading">
|
||||
<p id="login-modal-heading">Body</p>
|
||||
</Login>,
|
||||
);
|
||||
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(
|
||||
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
|
||||
|
||||
@@ -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(
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm
|
||||
variant="keepRule"
|
||||
magicLinkNextPath="/create/completed"
|
||||
/>
|
||||
</Suspense>,
|
||||
);
|
||||
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(
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm
|
||||
variant="keepRule"
|
||||
magicLinkNextPath="/create/completed"
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
</Suspense>,
|
||||
);
|
||||
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");
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<button type="button" onClick={() => openLogin()}>
|
||||
@@ -63,9 +64,33 @@ function LoginTrigger() {
|
||||
>
|
||||
Open save progress
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openLogin({
|
||||
variant: "keepRule",
|
||||
nextPath: "/create/completed",
|
||||
})
|
||||
}
|
||||
>
|
||||
Open keep rule
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openLogin({
|
||||
variant: "keepRule",
|
||||
nextPath: "/create/completed",
|
||||
onDismiss: () => setLeftCompleted(true),
|
||||
})
|
||||
}
|
||||
>
|
||||
Open keep rule then leave
|
||||
</button>
|
||||
<button type="button" onClick={() => closeLogin()}>
|
||||
Close from outside
|
||||
</button>
|
||||
{leftCompleted ? <p>Left completed</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<Suspense fallback={null}>
|
||||
<LoginTrigger />
|
||||
</Suspense>,
|
||||
);
|
||||
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(
|
||||
<Suspense fallback={null}>
|
||||
<LoginTrigger />
|
||||
</Suspense>,
|
||||
);
|
||||
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(
|
||||
<Suspense fallback={null}>
|
||||
<LoginTrigger />
|
||||
</Suspense>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user