Create flow: session UI + sign out

This commit is contained in:
adilallo
2026-04-06 19:22:50 -06:00
parent 4b14510dde
commit 759f5f1555
47 changed files with 1383 additions and 370 deletions
+151
View File
@@ -0,0 +1,151 @@
import React, { Suspense } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils";
import { useAuthModal } from "../../app/contexts/AuthModalContext";
const { navMock } = vi.hoisted(() => ({
navMock: {
pathname: "/",
searchParams: new URLSearchParams(),
replace: vi.fn(),
},
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
replace: navMock.replace,
push: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
}),
usePathname: () => navMock.pathname,
useSearchParams: () => navMock.searchParams,
}));
vi.mock("../../lib/create/api", () => ({
requestMagicLink: vi.fn(),
}));
vi.mock("../../app/create/anonymousDraftStorage", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../app/create/anonymousDraftStorage")
>();
return {
...actual,
setTransferPendingFlag: vi.fn(),
};
});
import { requestMagicLink } from "../../lib/create/api";
import { setTransferPendingFlag } from "../../app/create/anonymousDraftStorage";
function LoginTrigger() {
const { openLogin, closeLogin } = useAuthModal();
return (
<div>
<button type="button" onClick={() => openLogin()}>
Open login modal
</button>
<button
type="button"
onClick={() =>
openLogin({
variant: "saveProgress",
nextPath: "/create/select?syncDraft=1",
})
}
>
Open save progress
</button>
<button type="button" onClick={() => closeLogin()}>
Close from outside
</button>
</div>
);
}
describe("AuthModalProvider (header overlay)", () => {
beforeEach(() => {
vi.mocked(requestMagicLink).mockReset();
vi.mocked(setTransferPendingFlag).mockReset();
navMock.replace.mockReset();
navMock.pathname = "/";
navMock.searchParams = new URLSearchParams();
});
it("opens blurred overlay with LoginForm when openLogin is called", async () => {
const user = userEvent.setup();
renderWithProviders(
<Suspense fallback={null}>
<LoginTrigger />
</Suspense>,
);
await user.click(screen.getByRole("button", { name: /open login modal/i }));
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
const backdrop = screen.getByRole("dialog").parentElement;
expect(backdrop).toHaveClass("backdrop-blur-md");
expect(
screen.getByRole("heading", { name: /log in to communityrule/i }),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /back to home/i }),
).toBeInTheDocument();
});
it("closes overlay when closeLogin is called", async () => {
const user = userEvent.setup();
renderWithProviders(
<Suspense fallback={null}>
<LoginTrigger />
</Suspense>,
);
await user.click(screen.getByRole("button", { name: /open login modal/i }));
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
await user.click(
screen.getByRole("button", { name: /close from outside/i }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
it("saveProgress openLogin wires magicLinkNextPath and transfer flag on success", 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 save progress/i }),
);
await waitFor(() => {
expect(screen.getByRole("dialog")).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/select?syncDraft=1",
);
});
expect(setTransferPendingFlag).toHaveBeenCalled();
});
});
+6 -6
View File
@@ -1,6 +1,6 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
import CreateFlowTopNav from "../../app/components/utility/CreateFlowTopNav";
@@ -34,7 +34,7 @@ const config: ComponentTestSuiteConfig<CreateFlowTopNavProps> = {
hasShare: true,
hasExport: true,
hasEdit: true,
loggedIn: true,
saveDraftOnExit: true,
onShare: vi.fn(),
onExport: vi.fn(),
onEdit: vi.fn(),
@@ -60,14 +60,14 @@ describe("CreateFlowTopNav (behavioral tests)", () => {
expect(exitButton).toBeInTheDocument();
});
it("shows Save & Exit when loggedIn is true", () => {
render(<CreateFlowTopNav loggedIn={true} />);
it("shows Save & Exit when saveDraftOnExit is true", () => {
render(<CreateFlowTopNav saveDraftOnExit={true} />);
const exitButton = screen.getByRole("button", { name: "Save & Exit" });
expect(exitButton).toBeInTheDocument();
});
it("shows Exit when loggedIn is false", () => {
render(<CreateFlowTopNav loggedIn={false} />);
it("shows Exit when saveDraftOnExit is false", () => {
render(<CreateFlowTopNav saveDraftOnExit={false} />);
const exitButton = screen.getByRole("button", { name: "Exit" });
expect(exitButton).toBeInTheDocument();
});
+48
View File
@@ -10,6 +10,37 @@ describe("Login", () => {
vi.clearAllMocks();
});
it("uses blurredYellow backdrop by default (header overlay)", async () => {
renderWithProviders(
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
<p id="login-modal-heading">Login content</p>
</Login>,
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
const backdrop = screen.getByRole("dialog").parentElement;
expect(backdrop).toHaveClass("backdrop-blur-md");
});
it("uses solid backdrop when backdropVariant is solid (/login page)", async () => {
renderWithProviders(
<Login
isOpen
onClose={vi.fn()}
ariaLabelledBy="login-modal-heading"
backdropVariant="solid"
>
<p id="login-modal-heading">Login content</p>
</Login>,
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
const backdrop = screen.getByRole("dialog").parentElement;
expect(backdrop).not.toHaveClass("backdrop-blur-md");
});
it("renders dialog when open and portal is ready", async () => {
renderWithProviders(
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
@@ -35,6 +66,23 @@ describe("Login", () => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("portals overlay outside the rendered subtree by default", async () => {
const { container } = renderWithProviders(
<div data-testid="inline-root">
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
<p id="login-modal-heading">Portaled</p>
</Login>
</div>,
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
const dialog = screen.getByRole("dialog");
expect(
container.querySelector('[data-testid="inline-root"]')?.contains(dialog),
).toBe(false);
});
it("calls onClose when close button is clicked", async () => {
const onClose = vi.fn();
renderWithProviders(
+58 -2
View File
@@ -8,6 +8,8 @@ import LoginForm from "../../app/components/modals/Login/LoginForm";
const { navMock } = vi.hoisted(() => ({
navMock: {
/** Default: marketing route — header modal is the primary entry (not `/login`). */
pathname: "/",
searchParams: new URLSearchParams(),
replace: vi.fn(),
},
@@ -22,7 +24,7 @@ vi.mock("next/navigation", () => ({
back: vi.fn(),
forward: vi.fn(),
}),
usePathname: () => "/login",
usePathname: () => navMock.pathname,
useSearchParams: () => navMock.searchParams,
}));
@@ -30,7 +32,19 @@ vi.mock("../../lib/create/api", () => ({
requestMagicLink: vi.fn(),
}));
vi.mock("../../app/create/anonymousDraftStorage", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../app/create/anonymousDraftStorage")
>();
return {
...actual,
setTransferPendingFlag: vi.fn(),
};
});
import { requestMagicLink } from "../../lib/create/api";
import { setTransferPendingFlag } from "../../app/create/anonymousDraftStorage";
function renderLoginForm() {
return renderWithProviders(
@@ -43,7 +57,9 @@ function renderLoginForm() {
describe("LoginForm", () => {
beforeEach(() => {
vi.mocked(requestMagicLink).mockReset();
vi.mocked(setTransferPendingFlag).mockReset();
navMock.replace.mockReset();
navMock.pathname = "/";
navMock.searchParams = new URLSearchParams();
});
@@ -96,6 +112,33 @@ describe("LoginForm", () => {
expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument();
});
it("saveProgress variant uses magicLinkNextPath and sets transfer pending on success", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderWithProviders(
<Suspense fallback={null}>
<LoginForm
variant="saveProgress"
magicLinkNextPath="/create/select?syncDraft=1"
/>
</Suspense>,
);
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"save@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith(
"save@example.com",
"/create/select?syncDraft=1",
);
});
expect(setTransferPendingFlag).toHaveBeenCalled();
});
it("passes safe next path when next query param is set", async () => {
const user = userEvent.setup();
navMock.searchParams = new URLSearchParams("next=/learn");
@@ -158,8 +201,9 @@ describe("LoginForm", () => {
).toBeInTheDocument();
});
it("calls router.replace to clear error query when user types", async () => {
it("calls router.replace to clear error query when user types (full-page /login)", async () => {
const user = userEvent.setup();
navMock.pathname = "/login";
navMock.searchParams = new URLSearchParams("error=expired_link");
renderLoginForm();
await user.type(
@@ -169,6 +213,18 @@ describe("LoginForm", () => {
expect(navMock.replace).toHaveBeenCalledWith("/login", { scroll: false });
});
it("clears error query using current pathname when not on /login", async () => {
const user = userEvent.setup();
navMock.pathname = "/learn";
navMock.searchParams = new URLSearchParams("error=expired_link");
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"x",
);
expect(navMock.replace).toHaveBeenCalledWith("/learn", { scroll: false });
});
it("shows network error when request throws", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockRejectedValue(new Error("network"));