diff --git a/examples/react/lib/firebase/ui.tsx b/examples/react/lib/firebase/ui.tsx deleted file mode 100644 index 7567c84b..00000000 --- a/examples/react/lib/firebase/ui.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -"use client"; - -import { ui } from "./clientApp"; -import { ConfigProvider } from "@firebase-ui/react"; - -export function FirebaseUIProvider({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} diff --git a/examples/react/src/App.css b/examples/react/src/App.css deleted file mode 100644 index 2ef08e38..00000000 --- a/examples/react/src/App.css +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ diff --git a/examples/react/src/App.jsx b/examples/react/src/App.tsx similarity index 98% rename from examples/react/src/App.jsx rename to examples/react/src/App.tsx index 5c09e2d4..3ef015d3 100644 --- a/examples/react/src/App.jsx +++ b/examples/react/src/App.tsx @@ -15,7 +15,7 @@ */ import { NavLink } from "react-router"; -import { useUser } from "../lib/firebase/hooks"; +import { useUser } from "./firebase/hooks"; function App() { const user = useUser(); diff --git a/examples/react/src/assets/react.svg b/examples/react/src/assets/react.svg deleted file mode 100644 index 6c87de9b..00000000 --- a/examples/react/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/react/lib/components/header.tsx b/examples/react/src/components/header.tsx similarity index 97% rename from examples/react/lib/components/header.tsx rename to examples/react/src/components/header.tsx index 3887daaf..2e4a001d 100644 --- a/examples/react/lib/components/header.tsx +++ b/examples/react/src/components/header.tsx @@ -19,7 +19,7 @@ import { NavLink } from "react-router"; import { useUser } from "../firebase/hooks"; import { signOut, type User } from "firebase/auth"; -import { auth } from "../firebase/clientApp"; +import { auth } from "../firebase/firebase"; export function Header() { const user = useUser(); diff --git a/examples/react/lib/firebase/config.ts b/examples/react/src/firebase/config.ts similarity index 100% rename from examples/react/lib/firebase/config.ts rename to examples/react/src/firebase/config.ts diff --git a/examples/react/lib/firebase/clientApp.ts b/examples/react/src/firebase/firebase.ts similarity index 100% rename from examples/react/lib/firebase/clientApp.ts rename to examples/react/src/firebase/firebase.ts diff --git a/examples/react/lib/firebase/hooks.ts b/examples/react/src/firebase/hooks.ts similarity index 96% rename from examples/react/lib/firebase/hooks.ts rename to examples/react/src/firebase/hooks.ts index 14621ee3..efb0157d 100644 --- a/examples/react/lib/firebase/hooks.ts +++ b/examples/react/src/firebase/hooks.ts @@ -19,7 +19,7 @@ import { useState } from "react"; import { onAuthStateChanged } from "firebase/auth"; import { User } from "firebase/auth"; import { useEffect } from "react"; -import { auth } from "./clientApp"; +import { auth } from "./firebase"; export function useUser(initalUser?: User | null) { const [user, setUser] = useState(initalUser ?? null); diff --git a/examples/react/src/index.css b/examples/react/src/index.css index 731524bd..d2a6e9fa 100644 --- a/examples/react/src/index.css +++ b/examples/react/src/index.css @@ -15,7 +15,7 @@ */ @import "tailwindcss"; -@import "@firebase-ui/styles/src/base.css"; +@import "@firebase-ui/styles/tailwind"; /* @import "@firebase-ui/styles/src/themes/dark.css"; */ /* @import "@firebase-ui/styles/src/themes/brutalist.css"; */ diff --git a/examples/react/src/main.jsx b/examples/react/src/main.tsx similarity index 81% rename from examples/react/src/main.jsx rename to examples/react/src/main.tsx index cdca7fce..16705a86 100644 --- a/examples/react/src/main.jsx +++ b/examples/react/src/main.tsx @@ -14,14 +14,15 @@ * limitations under the License. */ -import { BrowserRouter, RouterProvider, Routes, Route } from "react-router"; +import { BrowserRouter, Routes, Route } from "react-router"; -import React from "react"; import ReactDOM from "react-dom/client"; +import { FirebaseUIProvider } from "@firebase-ui/react"; + +import { ui } from "./firebase/firebase"; import App from "./App"; -import { Header } from "../lib/components/header"; -import { FirebaseUIProvider } from "../lib/firebase/ui"; +import { Header } from "./components/header"; /** Sign In */ import SignInAuthScreenPage from "./screens/sign-in-auth-screen"; @@ -44,14 +45,20 @@ import SignUpAuthScreenWithOAuthPage from "./screens/sign-up-auth-screen"; import OAuthScreenPage from "./screens/oauth-screen"; /** Password Reset */ -import PasswordResetScreenPage from "./screens/password-reset-screen"; +import ForgotPasswordPage from "./screens/forgot-password-screen"; -const root = document.getElementById("root"); +const root = document.getElementById("root")!; ReactDOM.createRoot(root).render(
- + } /> } /> @@ -64,7 +71,7 @@ ReactDOM.createRoot(root).render( } /> } /> } /> - } /> + } /> diff --git a/examples/react/src/screens/password-reset-screen.tsx b/examples/react/src/screens/forgot-password-screen.tsx similarity index 77% rename from examples/react/src/screens/password-reset-screen.tsx rename to examples/react/src/screens/forgot-password-screen.tsx index f81d1c7d..28c7483e 100644 --- a/examples/react/src/screens/password-reset-screen.tsx +++ b/examples/react/src/screens/forgot-password-screen.tsx @@ -16,8 +16,8 @@ "use client"; -import { PasswordResetScreen } from "@firebase-ui/react"; +import { ForgotPasswordAuthScreen } from "@firebase-ui/react"; -export default function PasswordResetScreenPage() { - return {}} />; +export default function ForgotPasswordPage() { + return {}} />; } diff --git a/packages/react/tsconfig.app.json b/examples/react/tsconfig.json similarity index 76% rename from packages/react/tsconfig.app.json rename to examples/react/tsconfig.json index cc78ca33..66949f0f 100644 --- a/packages/react/tsconfig.app.json +++ b/examples/react/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], @@ -15,6 +14,8 @@ "noEmit": true, "jsx": "react-jsx", + "types": ["vite/client"], + /* Linting */ "strict": true, "noUnusedLocals": true, @@ -24,9 +25,7 @@ "baseUrl": ".", "paths": { "~/*": ["./src/*"], - "@firebase-ui/core": ["../core/src/index.ts"], - "@firebase-ui/styles": ["../styles/src/index.ts"] } }, - "include": ["src"] + "include": ["src", "vite.config.ts"] } diff --git a/examples/react/vite.config.js b/examples/react/vite.config.ts similarity index 80% rename from examples/react/vite.config.js rename to examples/react/vite.config.ts index b86fbcdd..8276a9f3 100644 --- a/examples/react/vite.config.js +++ b/examples/react/vite.config.ts @@ -17,7 +17,14 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; export default defineConfig({ plugins: [tailwindcss(), react()], + resolve: { + alias: { + "~": path.resolve(path.dirname(fileURLToPath(import.meta.url)), "./src"), + }, + }, }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 365ced65..9d1f3cb2 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -28,7 +28,7 @@ import { } from "./behaviors"; import { FirebaseUIState } from "./state"; -type FirebaseUIConfigurationOptions = { +export type FirebaseUIConfigurationOptions = { app: FirebaseApp; auth?: Auth; locale?: RegisteredLocale; diff --git a/packages/react/package.json b/packages/react/package.json index f70f1510..4651372a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -34,16 +34,16 @@ "release": "pnpm run build && pnpm pack --pack-destination --pack-destination ../../releases/" }, "peerDependencies": { - "@firebase-ui/core": "workspace:*", "firebase": "catalog:peerDependencies", "react": "catalog:peerDependencies", "react-dom": "catalog:peerDependencies" }, "dependencies": { + "@firebase-ui/core": "workspace:*", "@firebase-ui/styles": "workspace:*", "@nanostores/react": "^1.0.0", "@radix-ui/react-slot": "^1.2.3", - "@tanstack/react-form": "^0.41.3", + "@tanstack/react-form": "^1.20.0", "clsx": "^2.1.1", "tailwind-merge": "^3.0.1", "zod": "catalog:" diff --git a/packages/react/setup-test.ts b/packages/react/setup-test.ts index f302fe17..19a45aee 100644 --- a/packages/react/setup-test.ts +++ b/packages/react/setup-test.ts @@ -14,8 +14,4 @@ * limitations under the License. */ -import { expect } from "vitest"; -import * as matchers from "@testing-library/jest-dom/matchers"; - -// Extend Vitest's expect with jest-dom matchers -expect.extend(matchers); +import '@testing-library/jest-dom/vitest'; \ No newline at end of file diff --git a/packages/react/src/auth/forms/email-link-auth-form.test.tsx b/packages/react/src/auth/forms/email-link-auth-form.test.tsx index 5edb7c7c..00f232ce 100644 --- a/packages/react/src/auth/forms/email-link-auth-form.test.tsx +++ b/packages/react/src/auth/forms/email-link-auth-form.test.tsx @@ -14,278 +14,292 @@ * limitations under the License. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, act } from "@testing-library/react"; -import { EmailLinkAuthForm } from "./email-link-auth-form"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, renderHook, cleanup, waitFor } from "@testing-library/react"; +import { EmailLinkAuthForm, useEmailLinkAuthForm, useEmailLinkAuthFormAction, useEmailLinkAuthFormCompleteSignIn } from "./email-link-auth-form"; +import { act } from "react"; +import { sendSignInLinkToEmail, completeEmailLinkSignIn } from "@firebase-ui/core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { FirebaseUIProvider } from "~/context"; +import type { UserCredential } from "firebase/auth"; -// Mock Firebase UI Core vi.mock("@firebase-ui/core", async (importOriginal) => { const mod = await importOriginal(); - const FirebaseUIError = vi.fn(); - FirebaseUIError.prototype.message = "Test error message"; - return { ...mod, - FirebaseUIError: class FirebaseUIError { - message: string; - code?: string; - - constructor({ code, message }: { code: string; message: string }) { - this.code = code; - this.message = message; - } - }, - completeEmailLinkSignIn: vi.fn(), sendSignInLinkToEmail: vi.fn(), - createEmailLinkFormSchema: () => ({ - email: { - validate: (value: string) => { - if (!value) return "Email is required"; - return undefined; - }, - }, - }), + completeEmailLinkSignIn: vi.fn(), }; }); -import { FirebaseUIError, sendSignInLinkToEmail, completeEmailLinkSignIn } from "@firebase-ui/core"; - -// Mock React's useState to control state for testing -const useStateMock = vi.fn(); -const setFormErrorMock = vi.fn(); -const setEmailSentMock = vi.fn(); - -// Mock hooks -vi.mock("../../../../src/hooks", () => ({ - useUI: vi.fn(() => ({ - locale: "en-US", - translations: { - "en-US": { - labels: { - emailAddress: "Email", - sendSignInLink: "sendSignInLink", - }, - }, - }, - })), - useAuth: vi.fn(() => ({})), -})); - -// Mock form -vi.mock("@tanstack/react-form", () => ({ - useForm: () => { - const formState = { - email: "test@example.com", - }; - - return { - Field: ({ name, children }: any) => { - // Create a mock field with the required methods and state management - const field = { - name, - handleBlur: vi.fn(), - handleChange: vi.fn((value: string) => { - formState[name as keyof typeof formState] = value; - }), - state: { - value: formState[name as keyof typeof formState] || "", - meta: { isTouched: false, errors: [] }, - }, - }; - - return children(field); - }, - handleSubmit: vi.fn().mockImplementation(async () => { - // Call the onSubmit handler with the form state - await (global as any).formOnSubmit?.({ value: formState }); - }), - }; - }, -})); - -// Mock components -vi.mock("../../../../src/components/field-info", () => ({ - FieldInfo: () =>
, -})); - -vi.mock("../../../../src/components/policies", () => ({ - Policies: () =>
Policies
, -})); - -vi.mock("../../../../src/components/button", () => ({ - Button: ({ - children, - onClick, - type, - ...rest - }: { - children: React.ReactNode; - onClick?: () => void; - type?: "submit" | "reset" | "button"; - [key: string]: any; - }) => ( - - ), -})); - -// Mock react useState to control state in tests -vi.mock("react", async () => { - const actual = (await vi.importActual("react")) as typeof import("react"); +vi.mock("~/components/form", async (importOriginal) => { + const mod = await importOriginal(); return { - ...actual, - useState: vi.fn().mockImplementation((initialValue) => { - useStateMock(initialValue); - // For formError state - if (initialValue === null) { - return [null, setFormErrorMock]; - } - // For emailSent state - if (initialValue === false) { - return [false, setEmailSentMock]; - } - // Default behavior for other useState calls - return actual.useState(initialValue); - }), + ...mod, + form: { + ...mod.form, + ErrorMessage: () =>
Error Message
, + }, }; }); -const mockSendSignInLink = vi.mocked(sendSignInLinkToEmail); -const mockCompleteEmailLink = vi.mocked(completeEmailLinkSignIn); - -describe("EmailLinkAuthForm", () => { +describe("useEmailLinkAuthFormCompleteSignIn", () => { beforeEach(() => { vi.clearAllMocks(); - // Reset the global state - (global as any).formOnSubmit = null; - setFormErrorMock.mockReset(); - setEmailSentMock.mockReset(); }); - it("renders the email link form", () => { - render(); + it("should call onSignIn when email link sign-in is completed successfully", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn).mockResolvedValue(mockCredential); + const onSignInMock = vi.fn(); + const mockUI = createMockUI(); + + renderHook(() => useEmailLinkAuthFormCompleteSignIn(onSignInMock), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); - expect(screen.getByLabelText("Email")).toBeInTheDocument(); - expect(screen.getByText("sendSignInLink")).toBeInTheDocument(); + await waitFor(() => { + expect(completeEmailLinkSignInMock).toHaveBeenCalledWith(mockUI.get(), window.location.href); + expect(onSignInMock).toHaveBeenCalledWith(mockCredential); + }); }); - it("attempts to complete email link sign-in on load", () => { - mockCompleteEmailLink.mockResolvedValue(null); + it("should not call onSignIn when email link sign-in returns null", async () => { + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn).mockResolvedValue(null); + const onSignInMock = vi.fn(); + const mockUI = createMockUI(); - render(); + renderHook(() => useEmailLinkAuthFormCompleteSignIn(onSignInMock), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await waitFor(() => { + expect(completeEmailLinkSignInMock).toHaveBeenCalledWith(mockUI.get(), window.location.href); + expect(onSignInMock).not.toHaveBeenCalled(); + }); - expect(mockCompleteEmailLink).toHaveBeenCalled(); + expect(completeEmailLinkSignInMock).toHaveBeenCalledWith(mockUI.get(), window.location.href); + expect(onSignInMock).not.toHaveBeenCalled(); }); - it("submits the form and sends sign-in link to email", async () => { - mockSendSignInLink.mockResolvedValue(undefined); + it("should not call onSignIn when onSignIn is not provided", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn).mockResolvedValue(mockCredential); + const mockUI = createMockUI(); - const { container } = render(); + renderHook(() => useEmailLinkAuthFormCompleteSignIn(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await waitFor(() => { + expect(completeEmailLinkSignInMock).toHaveBeenCalledWith(mockUI.get(), window.location.href); + }); - // Get the form element - const form = container.getElementsByClassName("fui-form")[0] as HTMLFormElement; + }); +}); - // Set up the form submit handler - (global as any).formOnSubmit = async ({ value }: { value: { email: string } }) => { - await sendSignInLinkToEmail(expect.anything(), value.email); - }; +describe("useEmailLinkAuthFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a callback which accept an email", async () => { + const sendSignInLinkToEmailMock = vi.mocked(sendSignInLinkToEmail); + const mockUI = createMockUI(); + + const { result } = renderHook(() => useEmailLinkAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); - // Submit the form await act(async () => { - fireEvent.submit(form); + await result.current({ email: "test@example.com" }); }); - expect(mockSendSignInLink).toHaveBeenCalledWith(expect.anything(), "test@example.com"); + expect(sendSignInLinkToEmailMock).toHaveBeenCalledWith(expect.any(Object), "test@example.com"); }); - // TODO(ehesp): Fix this test - it.skip("handles error when sending email link fails", async () => { - // // Mock the error that will be thrown - // const mockError = new FirebaseUIError({ - // code: "auth/invalid-email", - // message: "Invalid email", - // }); - // mockSendSignInLink.mockRejectedValue(mockError); - - // const { container } = render(); - - // // Get the form element - // const form = container.getElementsByClassName("fui-form")[0] as HTMLFormElement; - - // // Set up the form submit handler to simulate error - // (global as any).formOnSubmit = async () => { - // try { - // // Simulate the action that would throw an error - // await sendSignInLinkToEmail(expect.anything(), "invalid-email"); - // } catch (_error) { - // // Simulate the error being caught and error state being set - // setFormErrorMock("Invalid email"); - // // Don't rethrow the error - we've handled it here - // } - // }; - - // // Submit the form - // await act(async () => { - // fireEvent.submit(form); - // }); - - // // Verify that the error state was updated - // expect(setFormErrorMock).toHaveBeenCalledWith("Invalid email"); + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const sendSignInLinkToEmailMock = vi + .mocked(sendSignInLinkToEmail) + .mockRejectedValue(new Error("Unknown error")); + + const mockUI = createMockUI({ + locale: registerLocale("es-ES", { + errors: { + unknownError: "unknownError", + }, + }), + }); + + const { result } = renderHook(() => useEmailLinkAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current({ email: "test@example.com" }); + }); + }).rejects.toThrow("unknownError"); + + expect(sendSignInLinkToEmailMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com"); }); +}); - it("handles success when email is sent", async () => { - mockSendSignInLink.mockResolvedValue(undefined); +describe("useEmailLinkAuthForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); - const { container } = render(); + it("should allow the form to be submitted", async () => { + const mockUI = createMockUI(); + const sendSignInLinkToEmailMock = vi.mocked(sendSignInLinkToEmail); - // Get the form element - const form = container.getElementsByClassName("fui-form")[0] as HTMLFormElement; + const { result } = renderHook(() => useEmailLinkAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); - // Set up the form submit handler - (global as any).formOnSubmit = async () => { - // Simulate successful email send by setting emailSent to true - setEmailSentMock(true); - }; + act(() => { + result.current.setFieldValue("email", "test@example.com"); + }); - // Submit the form await act(async () => { - fireEvent.submit(form); + await result.current.handleSubmit(); }); - // Verify that the success state was updated - expect(setEmailSentMock).toHaveBeenCalledWith(true); + expect(sendSignInLinkToEmailMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com"); }); - it("validates on blur for the first time", async () => { - render(); + it("should not allow the form to be submitted if the form is invalid", async () => { + const mockUI = createMockUI(); + const sendSignInLinkToEmailMock = vi.mocked(sendSignInLinkToEmail); - const emailInput = screen.getByLabelText("Email"); + const { result } = renderHook(() => useEmailLinkAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + result.current.setFieldValue("email", "123"); + }); await act(async () => { - fireEvent.blur(emailInput); + await result.current.handleSubmit(); }); - // Check that form validation is available - expect((global as any).formOnSubmit).toBeDefined(); + expect(result.current.getFieldMeta("email")!.errors[0].length).toBeGreaterThan(0); + expect(sendSignInLinkToEmailMock).not.toHaveBeenCalled(); }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendSignInLink: "sendSignInLink", + }, + }), + }); + + const { container } = render( + + + + ); - it("validates on input after first blur", async () => { - render(); + // There should be only one form + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); - const emailInput = screen.getByLabelText("Email"); + // Make sure we have an email input + expect(screen.getByRole("textbox", { name: /email/i })).toBeInTheDocument(); + + // Ensure the "Send Sign In Link" button is present and is a submit button + const sendSignInLinkButton = screen.getByRole("button", { name: "sendSignInLink" }); + expect(sendSignInLinkButton).toBeInTheDocument(); + expect(sendSignInLinkButton).toHaveAttribute("type", "submit"); + }); + + it("should attempt to complete email link sign-in on load", () => { + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn); + const mockUI = createMockUI(); + + render( + + + + ); + + expect(completeEmailLinkSignInMock).toHaveBeenCalled(); + }); + + it("should call onSignIn when email link sign-in is completed successfully", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn).mockResolvedValue(mockCredential); + const onSignInMock = vi.fn(); + const mockUI = createMockUI(); + + render( + + + + ); - // First validation on blur await act(async () => { - fireEvent.blur(emailInput); + // Wait for the useEffect to complete + await new Promise(resolve => setTimeout(resolve, 0)); }); - // Then validation should happen on input + expect(completeEmailLinkSignInMock).toHaveBeenCalledWith(mockUI.get(), window.location.href); + expect(onSignInMock).toHaveBeenCalledWith(mockCredential); + }); + + it("should not call onSignIn when email link sign-in returns null", async () => { + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn).mockResolvedValue(null); + const onSignInMock = vi.fn(); + const mockUI = createMockUI(); + + render( + + + + ); + await act(async () => { - fireEvent.input(emailInput, { target: { value: "test@example.com" } }); + // Wait for the useEffect to complete + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(completeEmailLinkSignInMock).toHaveBeenCalledWith(mockUI.get(), window.location.href); + expect(onSignInMock).not.toHaveBeenCalled(); + }); + + it('should trigger validation errors when the form is blurred', () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + const form = container.querySelector("form.fui-form"); + expect(form).toBeInTheDocument(); + + const input = screen.getByRole("textbox", { name: /email/i }); + + act(() => { + fireEvent.blur(input); }); - // Check that form validation is available - expect((global as any).formOnSubmit).toBeDefined(); + expect(screen.getByText("Please enter a valid email address")).toBeInTheDocument(); }); }); diff --git a/packages/react/src/auth/forms/email-link-auth-form.tsx b/packages/react/src/auth/forms/email-link-auth-form.tsx index e287a02a..93aa83a5 100644 --- a/packages/react/src/auth/forms/email-link-auth-form.tsx +++ b/packages/react/src/auth/forms/email-link-auth-form.tsx @@ -16,73 +16,87 @@ "use client"; -import { - FirebaseUIError, - completeEmailLinkSignIn, - createEmailLinkAuthFormSchema, - getTranslation, - sendSignInLinkToEmail, -} from "@firebase-ui/core"; -import { useForm } from "@tanstack/react-form"; -import { useEffect, useMemo, useState } from "react"; -import { useUI } from "~/hooks"; -import { Button } from "../../components/button"; -import { FieldInfo } from "../../components/field-info"; -import { Policies } from "../../components/policies"; +import { FirebaseUIError, completeEmailLinkSignIn, getTranslation, sendSignInLinkToEmail } from "@firebase-ui/core"; +import type { UserCredential } from "firebase/auth"; +import { useEmailLinkAuthFormSchema, useUI } from "~/hooks"; +import { form } from "~/components/form"; +import { Policies } from "~/components/policies"; +import { useCallback, useEffect, useState } from "react"; export type EmailLinkAuthFormProps = { onEmailSent?: () => void; + onSignIn?: (credential: UserCredential) => void; }; -export function EmailLinkAuthForm({ onEmailSent }: EmailLinkAuthFormProps) { +export function useEmailLinkAuthFormAction() { const ui = useUI(); - const [formError, setFormError] = useState(null); - const [emailSent, setEmailSent] = useState(false); - const [firstValidationOccured, setFirstValidationOccured] = useState(false); - - const emailLinkFormSchema = useMemo(() => createEmailLinkAuthFormSchema(ui), [ui]); - - const form = useForm({ - defaultValues: { - email: "", - }, - validators: { - onBlur: emailLinkFormSchema, - onSubmit: emailLinkFormSchema, - }, - onSubmit: async ({ value }) => { - setFormError(null); + return useCallback( + async ({ email }: { email: string }) => { try { - await sendSignInLinkToEmail(ui, value.email); - setEmailSent(true); - onEmailSent?.(); + return await sendSignInLinkToEmail(ui, email); } catch (error) { if (error instanceof FirebaseUIError) { - setFormError(error.message); - return; + throw new Error(error.message); } console.error(error); - setFormError(getTranslation(ui, "errors", "unknownError")); + throw new Error(getTranslation(ui, "errors", "unknownError")); } }, + [ui] + ); +} + +export function useEmailLinkAuthForm(onSuccess?: EmailLinkAuthFormProps["onEmailSent"]) { + const schema = useEmailLinkAuthFormSchema(); + const action = useEmailLinkAuthFormAction(); + + return form.useAppForm({ + defaultValues: { + email: "", + }, + validators: { + onBlur: schema, + onSubmit: schema, + onSubmitAsync: async ({ value }) => { + try { + await action(value); + return onSuccess?.(); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }, }); +} + +export function useEmailLinkAuthFormCompleteSignIn(onSignIn?: EmailLinkAuthFormProps["onSignIn"]) { + const ui = useUI(); - // Handle email link sign-in if URL contains the link useEffect(() => { const completeSignIn = async () => { - try { - await completeEmailLinkSignIn(ui, window.location.href); - } catch (error) { - if (error instanceof FirebaseUIError) { - setFormError(error.message); - } + const credential = await completeEmailLinkSignIn(ui, window.location.href); + + if (credential) { + onSignIn?.(credential); } }; void completeSignIn(); }, [ui]); +} + +export function EmailLinkAuthForm({ onEmailSent, onSignIn }: EmailLinkAuthFormProps) { + const ui = useUI(); + const [emailSent, setEmailSent] = useState(false); + + const form = useEmailLinkAuthForm(() => { + setEmailSent(true); + onEmailSent?.(); + }); + + useEmailLinkAuthFormCompleteSignIn(onSignIn); if (emailSent) { return
{getTranslation(ui, "messages", "signInLinkSent")}
; @@ -97,47 +111,16 @@ export function EmailLinkAuthForm({ onEmailSent }: EmailLinkAuthFormProps) { await form.handleSubmit(); }} > -
- ( - <> - - - )} - /> -
- - - -
- - {formError &&
{formError}
} -
+ +
+ } /> +
+ +
+ {getTranslation(ui, "labels", "sendSignInLink")} + +
+
); } diff --git a/packages/react/src/auth/forms/forgot-password-auth-form.test.tsx b/packages/react/src/auth/forms/forgot-password-auth-form.test.tsx index 20004c94..e2e2e002 100644 --- a/packages/react/src/auth/forms/forgot-password-auth-form.test.tsx +++ b/packages/react/src/auth/forms/forgot-password-auth-form.test.tsx @@ -14,218 +14,198 @@ * limitations under the License. */ -import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { ForgotPasswordAuthForm } from "./forgot-password-auth-form"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, renderHook, cleanup } from "@testing-library/react"; +import { ForgotPasswordAuthForm, useForgotPasswordAuthForm, useForgotPasswordAuthFormAction } from "./forgot-password-auth-form"; import { act } from "react"; +import { sendPasswordResetEmail } from "@firebase-ui/core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { FirebaseUIProvider } from "~/context"; -// Mock the dependencies vi.mock("@firebase-ui/core", async (importOriginal) => { const mod = await importOriginal(); return { ...mod, - sendPasswordResetEmail: vi.fn().mockImplementation(() => { - return Promise.resolve(); - }), - // FirebaseUIError: class FirebaseUIError extends Error { - // code: string; - // constructor(error: any) { - // super(error.message || "Unknown error"); - // this.name = "FirebaseUIError"; - // this.code = error.code || "unknown-error"; - // } - // }, - // createForgotPasswordFormSchema: vi.fn().mockReturnValue({ - // email: { required: "Email is required" }, - // }), + sendPasswordResetEmail: vi.fn(), }; }); -// Mock @tanstack/react-form library -vi.mock("@tanstack/react-form", () => { - const handleSubmitMock = vi.fn().mockImplementation((callback) => { - // Store the callback to call it directly in tests - (global as any).formSubmitCallback = callback; - return Promise.resolve(); +describe("useForgotPasswordAuthFormAction", () => { + beforeEach(() => { + vi.clearAllMocks(); }); - return { - useForm: vi.fn().mockImplementation(({ onSubmit }) => { - // Save the onSubmit function to call it directly in tests - (global as any).formOnSubmit = onSubmit; - - return { - handleSubmit: handleSubmitMock, - Field: ({ children, name }: any) => { - const field = { - name, - state: { - value: name === "email" ? "test@example.com" : "", - meta: { - isTouched: false, - errors: [], - }, - }, - handleBlur: vi.fn(), - handleChange: vi.fn(), - }; - return children(field); - }, - }; - }), - }; -}); + it("should return a callback which accept an email", async () => { + const sendPasswordResetEmailMock = vi.mocked(sendPasswordResetEmail); + const mockUI = createMockUI(); -vi.mock("../../../../src/hooks", () => ({ - useAuth: vi.fn().mockReturnValue({}), - useUI: vi.fn().mockReturnValue({ - locale: "en-US", - translations: { - "en-US": { - labels: { - backToSignIn: "back button", + const { result } = renderHook(() => useForgotPasswordAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ email: "test@example.com" }); + }); + + expect(sendPasswordResetEmailMock).toHaveBeenCalledWith(expect.any(Object), "test@example.com"); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const sendPasswordResetEmailMock = vi + .mocked(sendPasswordResetEmail) + .mockRejectedValue(new Error("Unknown error")); + + const mockUI = createMockUI({ + locale: registerLocale("es-ES", { + errors: { + unknownError: "unknownError", }, - }, - }, - }), -})); - -// Mock the components -vi.mock("../../../../src/components/field-info", () => ({ - FieldInfo: vi - .fn() - .mockImplementation(({ field }) => ( -
- {field.state.meta.errors.length > 0 && {field.state.meta.errors[0]}} -
- )), -})); - -vi.mock("../../../../src/components/policies", () => ({ - Policies: vi.fn().mockReturnValue(
), -})); - -vi.mock("../../../../src/components/button", () => ({ - Button: vi.fn().mockImplementation(({ children, type, onClick }) => ( - - )), -})); - -// Import the actual functions after mocking -import { sendPasswordResetEmail } from "@firebase-ui/core"; + }), + }); + + const { result } = renderHook(() => useForgotPasswordAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await expect(async () => { + await act(async () => { + await result.current({ email: "test@example.com" }); + }); + }).rejects.toThrow("unknownError"); + + expect(sendPasswordResetEmailMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com"); + }); +}); -describe("ForgotPasswordForm", () => { +describe("useForgotPasswordAuthForm", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("renders the form correctly", () => { - render(); - - expect(screen.getByRole("textbox", { name: /email address/i })).toBeInTheDocument(); - expect(screen.getByTestId("submit-button")).toBeInTheDocument(); + afterEach(() => { + cleanup(); }); - it("submits the form when the button is clicked", async () => { - render(); + it("should allow the form to be submitted", async () => { + const mockUI = createMockUI(); + const sendPasswordResetEmailMock = vi.mocked(sendPasswordResetEmail); - // Get the submit button - const submitButton = screen.getByTestId("submit-button"); + const { result } = renderHook(() => useForgotPasswordAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + result.current.setFieldValue("email", "test@example.com"); + }); - // Trigger form submission await act(async () => { - fireEvent.click(submitButton); - - // Directly call the onSubmit function with form values - if ((global as any).formOnSubmit) { - await (global as any).formOnSubmit({ - value: { - email: "test@example.com", - }, - }); - } + await result.current.handleSubmit(); }); - // Check that the password reset function was called - expect(sendPasswordResetEmail).toHaveBeenCalledWith(expect.anything(), "test@example.com"); + expect(sendPasswordResetEmailMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com"); }); - it("displays error message when password reset fails", async () => { - // Mock the reset function to reject with an error - const mockError = new Error("Invalid email"); - (sendPasswordResetEmail as Mock).mockRejectedValueOnce(mockError); + it("should not allow the form to be submitted if the form is invalid", async () => { + const mockUI = createMockUI(); + const sendPasswordResetEmailMock = vi.mocked(sendPasswordResetEmail); - render(); + const { result } = renderHook(() => useForgotPasswordAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); - // Get the submit button - const submitButton = screen.getByTestId("submit-button"); + act(() => { + result.current.setFieldValue("email", "123"); + }); - // Trigger form submission await act(async () => { - fireEvent.click(submitButton); - - // Directly call the onSubmit function with form values - if ((global as any).formOnSubmit) { - await (global as any) - .formOnSubmit({ - value: { - email: "test@example.com", - }, - }) - .catch(() => { - // Catch the error here to prevent test from failing - }); - } + await result.current.handleSubmit(); }); - // Check that the password reset function was called - expect(sendPasswordResetEmail).toHaveBeenCalled(); + expect(result.current.getFieldMeta("email")!.errors[0].length).toBeGreaterThan(0); + expect(sendPasswordResetEmailMock).not.toHaveBeenCalled(); }); +}); - it("validates on blur for the first time", async () => { - render(); - - const emailInput = screen.getByRole("textbox", { name: /email address/i }); +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - await act(async () => { - fireEvent.blur(emailInput); + it("should render the form correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + resetPassword: "resetPassword", + }, + }), }); - // Check that handleBlur was called - expect((global as any).formOnSubmit).toBeDefined(); - }); + const { container } = render( + + + + ); - it("validates on input after first blur", async () => { - render(); + // There should be only one form + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); - const emailInput = screen.getByRole("textbox", { name: /email address/i }); + // Make sure we have an email input + expect(screen.getByRole("textbox", { name: /email/i })).toBeInTheDocument(); - // First validation on blur - await act(async () => { - fireEvent.blur(emailInput); - }); + // Ensure the "Reset Password" button is present and is a submit button + const resetPasswordButton = screen.getByRole("button", { name: "resetPassword" }); + expect(resetPasswordButton).toBeInTheDocument(); + expect(resetPasswordButton).toHaveAttribute("type", "submit"); + }); - // Then validation should happen on input - await act(async () => { - fireEvent.input(emailInput, { target: { value: "test@example.com" } }); + it("should render the back to sign in button callback when onBackToSignInClick is provided", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + backToSignIn: "backToSignIn", + }, + }), }); - // Check that handleBlur and form.update were called - expect((global as any).formOnSubmit).toBeDefined(); - }); - - // TODO: Fix this test - it.skip("displays back to sign in button when provided", () => { const onBackToSignInClickMock = vi.fn(); - render(); - const backButton = screen.getByText(/back button/i); - expect(backButton).toHaveClass("fui-form__action"); - expect(backButton).toBeInTheDocument(); + render( + + + + ); - fireEvent.click(backButton); + const backToSignInButton = screen.getByRole("button", { name: "backToSignIn" }); + expect(backToSignInButton).toBeInTheDocument(); + expect(backToSignInButton).toHaveTextContent("backToSignIn"); + + // Make sure it's a button so it doesn't submit the form + expect(backToSignInButton).toHaveAttribute("type", "button"); + + fireEvent.click(backToSignInButton); expect(onBackToSignInClickMock).toHaveBeenCalled(); }); + + it('should trigger validation errors when the form is blurred', () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + const form = container.querySelector("form.fui-form"); + expect(form).toBeInTheDocument(); + + const input = screen.getByRole("textbox", { name: /email/i }); + + act(() => { + fireEvent.blur(input); + }); + + expect(screen.getByText("Please enter a valid email address")).toBeInTheDocument(); + }); }); diff --git a/packages/react/src/auth/forms/forgot-password-auth-form.tsx b/packages/react/src/auth/forms/forgot-password-auth-form.tsx index 680265bb..783885ff 100644 --- a/packages/react/src/auth/forms/forgot-password-auth-form.tsx +++ b/packages/react/src/auth/forms/forgot-password-auth-form.tsx @@ -16,57 +16,66 @@ "use client"; -import { - createForgotPasswordAuthFormSchema, - FirebaseUIError, - getTranslation, - sendPasswordResetEmail, - type ForgotPasswordAuthFormSchema, -} from "@firebase-ui/core"; -import { useForm } from "@tanstack/react-form"; -import { useMemo, useState } from "react"; -import { useUI } from "~/hooks"; -import { Button } from "../../components/button"; -import { FieldInfo } from "../../components/field-info"; -import { Policies } from "../../components/policies"; +import { FirebaseUIError, getTranslation, sendPasswordResetEmail } from "@firebase-ui/core"; +import { useForgotPasswordAuthFormSchema, useUI } from "~/hooks"; +import { form } from "~/components/form"; +import { Policies } from "~/components/policies"; +import { useCallback, useState } from "react"; export type ForgotPasswordAuthFormProps = { onPasswordSent?: () => void; onBackToSignInClick?: () => void; -} +}; -export function ForgotPasswordAuthForm({ onBackToSignInClick, onPasswordSent }: ForgotPasswordAuthFormProps) { +export function useForgotPasswordAuthFormAction() { const ui = useUI(); - const [formError, setFormError] = useState(null); - const [emailSent, setEmailSent] = useState(false); - const [firstValidationOccured, setFirstValidationOccured] = useState(false); - const forgotPasswordFormSchema = useMemo(() => createForgotPasswordAuthFormSchema(ui), [ui]); - - const form = useForm({ - defaultValues: { - email: "", - }, - validators: { - onBlur: forgotPasswordFormSchema, - onSubmit: forgotPasswordFormSchema, - }, - onSubmit: async ({ value }) => { - setFormError(null); + return useCallback( + async ({ email }: { email: string }) => { try { - await sendPasswordResetEmail(ui, value.email); - setEmailSent(true); - onPasswordSent?.(); + return await sendPasswordResetEmail(ui, email); } catch (error) { if (error instanceof FirebaseUIError) { - setFormError(error.message); - return; + throw new Error(error.message); } console.error(error); - setFormError(getTranslation(ui, "errors", "unknownError")); + throw new Error(getTranslation(ui, "errors", "unknownError")); } }, + [ui] + ); +} + +export function useForgotPasswordAuthForm(onSuccess?: ForgotPasswordAuthFormProps["onPasswordSent"]) { + const schema = useForgotPasswordAuthFormSchema(); + const action = useForgotPasswordAuthFormAction(); + + return form.useAppForm({ + defaultValues: { + email: "", + }, + validators: { + onBlur: schema, + onSubmit: schema, + onSubmitAsync: async ({ value }) => { + try { + await action(value); + return onSuccess?.(); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }, + }); +} + +export function ForgotPasswordAuthForm({ onBackToSignInClick, onPasswordSent }: ForgotPasswordAuthFormProps) { + const ui = useUI(); + const [emailSent, setEmailSent] = useState(false); + const form = useForgotPasswordAuthForm(() => { + setEmailSent(true); + onPasswordSent?.(); }); if (emailSent) { @@ -82,60 +91,23 @@ export function ForgotPasswordAuthForm({ onBackToSignInClick, onPasswordSent }: await form.handleSubmit(); }} > -
- ( - <> - - - )} - /> -
- - - -
- - {formError &&
{formError}
} -
- - {onBackToSignInClick && ( -
- -
- )} + +
+ } /> +
+ +
+ + {getTranslation(ui, "labels", "resetPassword")} + + +
+ {onBackToSignInClick ? ( + + {getTranslation(ui, "labels", "backToSignIn")} + + ) : null} +
); } diff --git a/packages/react/src/auth/forms/phone-auth-form.test.tsx b/packages/react/src/auth/forms/phone-auth-form.test.tsx index e4df0d6f..6f9c51d8 100644 --- a/packages/react/src/auth/forms/phone-auth-form.test.tsx +++ b/packages/react/src/auth/forms/phone-auth-form.test.tsx @@ -14,9 +14,9 @@ * limitations under the License. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { PhoneAuthForm } from "./phone-auth-form"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, renderHook, cleanup } from "@testing-library/react"; +import { PhoneAuthForm, usePhoneAuthFormAction, usePhoneVerificationFormAction, usePhoneResendAction, useResendTimer } from "./phone-auth-form"; import { act } from "react"; // Mock Firebase Auth @@ -30,108 +30,33 @@ vi.mock("firebase/auth", () => ({ })); // Mock the core dependencies -vi.mock("@firebase-ui/core", async (originalImport) => { - const mod = await originalImport(); +vi.mock("@firebase-ui/core", async (importOriginal) => { + const mod = await importOriginal(); return { ...mod, - signInWithPhoneNumber: vi.fn().mockResolvedValue({ - confirm: vi.fn().mockResolvedValue(undefined), - }), - confirmPhoneNumber: vi.fn().mockResolvedValue(undefined), - createPhoneFormSchema: vi.fn().mockReturnValue({ - phoneNumber: { required: "Phone number is required" }, - verificationCode: { required: "Verification code is required" }, - pick: vi.fn().mockReturnValue({ - phoneNumber: { required: "Phone number is required" }, - }), - }), + signInWithPhoneNumber: vi.fn(), + confirmPhoneNumber: vi.fn(), formatPhoneNumberWithCountry: vi.fn((phoneNumber, dialCode) => `${dialCode}${phoneNumber}`), }; }); -// Mock @tanstack/react-form library -vi.mock("@tanstack/react-form", () => { - const handleSubmitMock = vi.fn().mockImplementation((callback) => { - // Store the callback to call it directly in tests - (global as any).formSubmitCallback = callback; - return Promise.resolve(); - }); - +vi.mock("~/components/form", async (importOriginal) => { + const mod = await importOriginal(); return { - useForm: vi.fn().mockImplementation(({ onSubmit }) => { - // Save the onSubmit function to call it directly in tests - (global as any).formOnSubmit = onSubmit; - - return { - handleSubmit: handleSubmitMock, - Field: ({ children, name }: any) => { - const field = { - name, - state: { - value: name === "phoneNumber" ? "1234567890" : "123456", - meta: { - isTouched: false, - errors: [], - }, - }, - handleBlur: vi.fn(), - handleChange: vi.fn(), - }; - return children(field); - }, - }; - }), + ...mod, + form: { + ...mod.form, + ErrorMessage: () =>
Error Message
, + }, }; }); -// Mock hooks -vi.mock("../../../../src/hooks", () => ({ - useAuth: vi.fn().mockReturnValue({}), - useUI: vi.fn().mockReturnValue({ - locale: "en-US", - translations: { - "en-US": { - labels: { - phoneNumber: "Phone Number", - verificationCode: "Verification Code", - sendVerificationCode: "Send Verification Code", - resendVerificationCode: "Resend Verification Code", - enterVerificationCode: "Enter Verification Code", - continue: "Continue", - backToSignIn: "Back to Sign In", - }, - errors: { - unknownError: "Unknown error", - }, - }, - }, - }), -})); - -// Mock the components -vi.mock("../../../../src/components/field-info", () => ({ - FieldInfo: vi - .fn() - .mockImplementation(({ field }) => ( -
- {field.state.meta.errors.length > 0 && {field.state.meta.errors[0]}} -
- )), -})); - -vi.mock("../../../../src/components/policies", () => ({ - Policies: vi.fn().mockReturnValue(
), -})); +import { signInWithPhoneNumber, confirmPhoneNumber } from "@firebase-ui/core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { FirebaseUIProvider } from "~/context"; -vi.mock("../../../../src/components/button", () => ({ - Button: vi.fn().mockImplementation(({ children, type, onClick }) => ( - - )), -})); - -vi.mock("../../../../src/components/country-selector", () => ({ +vi.mock("~/components/country-selector", () => ({ CountrySelector: vi.fn().mockImplementation(({ value, onChange }) => (
0} - id={field.name} - name={field.name} - type="text" - value={field.state.value} - onBlur={() => { - setFirstValidationOccured(true); - field.handleBlur(); - }} - onInput={(e) => { - field.handleChange((e.target as HTMLInputElement).value); - if (firstValidationOccured) { - field.handleBlur(); - verificationForm.update(); - } - }} - /> - - - - )} - /> - - -
-
-
- - - -
- - - {formError &&
{formError}
} -
+ +
+ } /> +
+ +
+
+
+ + + +
+ + {getTranslation(ui, "labels", "verifyCode")} + + + {isResending + ? getTranslation(ui, "labels", "sending") + : !canResend + ? `${getTranslation(ui, "labels", "resendCode")} (${timeLeft}s)` + : getTranslation(ui, "labels", "resendCode")} + + +
+
); } @@ -302,7 +316,6 @@ export type PhoneAuthFormProps = { export function PhoneAuthForm({ resendDelay = 30 }: PhoneAuthFormProps) { const ui = useUI(); - const [formError, setFormError] = useState(null); const [confirmationResult, setConfirmationResult] = useState(null); const [recaptchaVerifier, setRecaptchaVerifier] = useState(null); const [phoneNumber, setPhoneNumber] = useState(""); @@ -310,6 +323,10 @@ export function PhoneAuthForm({ resendDelay = 30 }: PhoneAuthFormProps) { const recaptchaContainerRef = useRef(null); const { timeLeft, canResend, startTimer } = useResendTimer(resendDelay); + const phoneAuthAction = usePhoneAuthFormAction(); + const phoneVerificationAction = usePhoneVerificationFormAction(); + const phoneResendAction = usePhoneResendAction(); + useEffect(() => { if (!recaptchaContainerRef.current) return; @@ -327,23 +344,19 @@ export function PhoneAuthForm({ resendDelay = 30 }: PhoneAuthFormProps) { }, [ui]); const handlePhoneSubmit = async (number: string) => { - setFormError(null); try { if (!recaptchaVerifier) { throw new Error("ReCAPTCHA not initialized"); } - const result = await signInWithPhoneNumber(ui, number, recaptchaVerifier); + const result = await phoneAuthAction({ phoneNumber: number, recaptchaVerifier }); setPhoneNumber(number); setConfirmationResult(result); startTimer(); } catch (error) { - if (error instanceof FirebaseUIError) { - setFormError(error.message); - return; - } - console.error(error); - setFormError(getTranslation(ui, "errors", "unknownError")); + // Error handling is now managed by the form system + console.error("Phone submission failed:", error); + throw error; } }; @@ -353,7 +366,6 @@ export function PhoneAuthForm({ resendDelay = 30 }: PhoneAuthFormProps) { } setIsResending(true); - setFormError(null); try { if (recaptchaVerifier) { @@ -366,16 +378,12 @@ export function PhoneAuthForm({ resendDelay = 30 }: PhoneAuthFormProps) { }); setRecaptchaVerifier(verifier); - const result = await signInWithPhoneNumber(ui, phoneNumber, verifier); + const result = await phoneResendAction({ phoneNumber, recaptchaVerifier: verifier }); setConfirmationResult(result); startTimer(); } catch (error) { - if (error instanceof FirebaseUIError) { - setFormError(error.message); - } else { - console.error(error); - setFormError(getTranslation(ui, "errors", "unknownError")); - } + console.error("Phone resend failed:", error); + // Error handling is now managed by the form system } finally { setIsResending(false); } @@ -386,17 +394,12 @@ export function PhoneAuthForm({ resendDelay = 30 }: PhoneAuthFormProps) { throw new Error("Confirmation result not initialized"); } - setFormError(null); - try { - await confirmPhoneNumber(ui, confirmationResult, code); + await phoneVerificationAction({ confirmationResult, code }); } catch (error) { - if (error instanceof FirebaseUIError) { - setFormError(error.message); - return; - } - console.error(error); - setFormError(getTranslation(ui, "errors", "unknownError")); + // Error handling is now managed by the form system + console.error("Phone verification failed:", error); + throw error; } }; @@ -406,7 +409,6 @@ export function PhoneAuthForm({ resendDelay = 30 }: PhoneAuthFormProps) { diff --git a/packages/react/src/auth/forms/sign-in-auth-form.test.tsx b/packages/react/src/auth/forms/sign-in-auth-form.test.tsx index f996ce5c..bed03d6a 100644 --- a/packages/react/src/auth/forms/sign-in-auth-form.test.tsx +++ b/packages/react/src/auth/forms/sign-in-auth-form.test.tsx @@ -14,201 +14,264 @@ * limitations under the License. */ -import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { SignInAuthForm } from "./sign-in-auth-form"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, renderHook, cleanup } from "@testing-library/react"; +import { SignInAuthForm, useSignInAuthForm, useSignInAuthFormAction } from "./sign-in-auth-form"; import { act } from "react"; +import { signInWithEmailAndPassword } from "@firebase-ui/core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import type { UserCredential } from "firebase/auth"; +import { FirebaseUIProvider } from "~/context"; -// Mock the dependencies vi.mock("@firebase-ui/core", async (importOriginal) => { const mod = await importOriginal(); return { ...mod, - signInWithEmailAndPassword: vi.fn().mockResolvedValue(undefined), - FirebaseUIError: class FirebaseUIError extends Error { - constructor(error: any) { - super(error.message || "Unknown error"); - this.name = "FirebaseUIError"; - } - }, + signInWithEmailAndPassword: vi.fn(), }; }); -// Mock @tanstack/react-form library -vi.mock("@tanstack/react-form", () => { - const handleSubmitMock = vi.fn().mockImplementation((callback) => { - // Store the callback to call it directly in tests - (global as any).formSubmitCallback = callback; - return Promise.resolve(); - }); - +vi.mock("~/components/form", async (importOriginal) => { + const mod = await importOriginal(); return { - useForm: vi.fn().mockImplementation(({ onSubmit }) => { - // Save the onSubmit function to call it directly in tests - (global as any).formOnSubmit = onSubmit; - - return { - handleSubmit: handleSubmitMock, - Field: ({ children, name }: any) => { - const field = { - name, - state: { - value: name === "email" ? "test@example.com" : "password123", - meta: { - isTouched: false, - errors: [], - }, - }, - handleBlur: vi.fn(), - handleChange: vi.fn(), - }; - return children(field); - }, - }; - }), + ...mod, + form: { + ...mod.form, + ErrorMessage: () =>
Error Message
, + }, }; }); -vi.mock("../../../../src/hooks", () => ({ - useAuth: vi.fn().mockReturnValue({}), - useUI: vi.fn().mockReturnValue({ - translations: { - "en-US": { - labels: { - emailAddress: "Email Address", - }, - }, - }, - }), -})); - -// Mock the components -vi.mock("../../../../src/components/field-info", () => ({ - FieldInfo: vi - .fn() - .mockImplementation(({ field }) => ( -
- {field.state.meta.errors.length > 0 && {field.state.meta.errors[0]}} -
- )), -})); - -vi.mock("../../../../src/components/policies", () => ({ - Policies: vi.fn().mockReturnValue(
), -})); - -vi.mock("../../../../src/components/button", () => ({ - Button: vi.fn().mockImplementation(({ children, type, onClick }) => ( - - )), -})); - -// Import the actual functions after mocking -import { signInWithEmailAndPassword } from "@firebase-ui/core"; - -describe("SignInAuthForm", () => { +describe("useSignInAuthFormAction", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("renders the form correctly", () => { - render(); + it("should return a callback which accept an email and password", async () => { + const signInWithEmailAndPasswordMock = vi.mocked(signInWithEmailAndPassword); + const mockUI = createMockUI(); + + const { result } = renderHook(() => useSignInAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ email: "test@example.com", password: "password123" }); + }); - expect(screen.getByRole("textbox", { name: /email address/i })).toBeInTheDocument(); - expect(screen.getByTestId("policies")).toBeInTheDocument(); - expect(screen.getByTestId("submit-button")).toBeInTheDocument(); + expect(signInWithEmailAndPasswordMock).toHaveBeenCalledWith(expect.any(Object), "test@example.com", "password123"); }); - it("submits the form when the button is clicked", async () => { - render(); + it("should return a credential on success", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + + const signInWithEmailAndPasswordMock = vi.mocked(signInWithEmailAndPassword).mockResolvedValue(mockCredential); - // Get the submit button - const submitButton = screen.getByTestId("submit-button"); + const mockUI = createMockUI(); + + const { result } = renderHook(() => useSignInAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); - // Trigger form submission await act(async () => { - fireEvent.click(submitButton); + const credential = await result.current({ email: "test@example.com", password: "password123" }); + expect(credential).toBe(mockCredential); + }); + + expect(signInWithEmailAndPasswordMock).toHaveBeenCalledWith(expect.any(Object), "test@example.com", "password123"); + }); + + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const signInWithEmailAndPasswordMock = vi + .mocked(signInWithEmailAndPassword) + .mockRejectedValue(new Error("Unknown error")); + + const mockUI = createMockUI({ + locale: registerLocale("es-ES", { + errors: { + unknownError: "unknownError", + }, + }), + }); - // Directly call the onSubmit function with form values - if ((global as any).formOnSubmit) { - await (global as any).formOnSubmit({ - value: { - email: "test@example.com", - password: "password123", - }, - }); - } + const { result } = renderHook(() => useSignInAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), }); - // Check that the authentication function was called - expect(signInWithEmailAndPassword).toHaveBeenCalledWith(expect.anything(), "test@example.com", "password123"); + await expect(async () => { + await act(async () => { + await result.current({ email: "test@example.com", password: "password123" }); + }); + }).rejects.toThrow("unknownError"); + + expect(signInWithEmailAndPasswordMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com", "password123"); + }); +}); + +describe("useSignInAuthForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); }); - it("displays error message when sign in fails", async () => { - // Mock the sign in function to reject with an error - const mockError = new Error("Invalid credentials"); - (signInWithEmailAndPassword as Mock).mockRejectedValueOnce(mockError); + it("should allow the form to be submitted", async () => { + const mockUI = createMockUI(); + const signInWithEmailAndPasswordMock = vi.mocked(signInWithEmailAndPassword); - render(); + const { result } = renderHook(() => useSignInAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); - // Get the submit button - const submitButton = screen.getByTestId("submit-button"); + act(() => { + result.current.setFieldValue("email", "test@example.com"); + result.current.setFieldValue("password", "password123"); + }); - // Trigger form submission await act(async () => { - fireEvent.click(submitButton); - - // Directly call the onSubmit function with form values - if ((global as any).formOnSubmit) { - await (global as any).formOnSubmit({ - value: { - email: "test@example.com", - password: "password123", - }, - }); - } + await result.current.handleSubmit(); }); - // Check that the authentication function was called - expect(signInWithEmailAndPassword).toHaveBeenCalled(); + expect(signInWithEmailAndPasswordMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com", "password123"); }); - it("validates on blur for the first time", async () => { - render(); + it("should not allow the form to be submitted if the form is invalid", async () => { + const mockUI = createMockUI(); + const signInWithEmailAndPasswordMock = vi.mocked(signInWithEmailAndPassword); - const emailInput = screen.getByRole("textbox", { name: /email address/i }); - const passwordInput = screen.getByDisplayValue("password123"); + const { result } = renderHook(() => useSignInAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + result.current.setFieldValue("email", "123"); + }); await act(async () => { - fireEvent.blur(emailInput); - fireEvent.blur(passwordInput); + await result.current.handleSubmit(); }); - // Check that handleBlur was called - expect((global as any).formOnSubmit).toBeDefined(); + expect(result.current.getFieldMeta("email")!.errors[0].length).toBeGreaterThan(0); + expect(signInWithEmailAndPasswordMock).not.toHaveBeenCalled(); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); }); - it("validates on input after first blur", async () => { - render(); + it("should render the form correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "signIn", + }, + }), + }); - const emailInput = screen.getByRole("textbox", { name: /email address/i }); - const passwordInput = screen.getByDisplayValue("password123"); + const { container } = render( + + + + ); - // First validation on blur - await act(async () => { - fireEvent.blur(emailInput); - fireEvent.blur(passwordInput); + // There should be only one form + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + // Make sure we have an email and password input + expect(screen.getByRole("textbox", { name: /email/i })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /password/i })).toBeInTheDocument(); + + // Ensure the "Sign In" button is present and is a submit button + const signInButton = screen.getByRole("button", { name: "signIn" }); + expect(signInButton).toBeInTheDocument(); + expect(signInButton).toHaveAttribute("type", "submit"); + }); + + it("should render the forgot password button callback when onForgotPasswordClick is provided", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + forgotPassword: "forgotPassword", + }, + }), }); - // Then validation should happen on input - await act(async () => { - fireEvent.input(emailInput, { target: { value: "test@example.com" } }); - fireEvent.input(passwordInput, { target: { value: "password123" } }); + const onForgotPasswordClickMock = vi.fn(); + + render( + + + + ); + + const forgotPasswordButton = screen.getByRole("button", { name: "forgotPassword" }); + expect(forgotPasswordButton).toBeInTheDocument(); + expect(forgotPasswordButton).toHaveTextContent("forgotPassword"); + + // Make sure it's a button so it doesn't submit the form + expect(forgotPasswordButton).toHaveAttribute("type", "button"); + + fireEvent.click(forgotPasswordButton); + expect(onForgotPasswordClickMock).toHaveBeenCalled(); + }); + + it("should render the register button callback when onRegisterClick is provided", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + prompts: { + noAccount: "foo", + }, + labels: { + register: "bar", + }, + }), + }); + + const onRegisterClickMock = vi.fn(); + + render( + + + + ); + + const name = "foo bar"; + + const registerButton = screen.getByRole("button", { name }); + expect(registerButton).toBeInTheDocument(); + expect(registerButton).toHaveTextContent(name); + + // Make sure it's a button so it doesn't submit the form + expect(registerButton).toHaveAttribute("type", "button"); + + fireEvent.click(registerButton); + expect(onRegisterClickMock).toHaveBeenCalled(); + }); + + it('should trigger validation errors when the form is blurred', () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + const form = container.querySelector("form.fui-form"); + expect(form).toBeInTheDocument(); + + const input = screen.getByRole("textbox", { name: /email/i }); + + act(() => { + fireEvent.blur(input); }); - // Check that handleBlur and form.update were called - expect((global as any).formOnSubmit).toBeDefined(); + expect(screen.getByText("Please enter a valid email address")).toBeInTheDocument(); }); }); diff --git a/packages/react/src/auth/forms/sign-in-auth-form.tsx b/packages/react/src/auth/forms/sign-in-auth-form.tsx index c1bb8d0d..c6d19061 100644 --- a/packages/react/src/auth/forms/sign-in-auth-form.tsx +++ b/packages/react/src/auth/forms/sign-in-auth-form.tsx @@ -16,61 +16,66 @@ "use client"; -import { - createSignInAuthFormSchema, - FirebaseUIError, - getTranslation, - signInWithEmailAndPassword, - type SignInAuthFormSchema, -} from "@firebase-ui/core"; -import { useForm } from "@tanstack/react-form"; -import { useMemo, useState } from "react"; -import { useUI } from "~/hooks"; -import { Button } from "../../components/button"; -import { FieldInfo } from "../../components/field-info"; -import { Policies } from "../../components/policies"; -import { UserCredential } from "firebase/auth"; +import { FirebaseUIError, getTranslation, signInWithEmailAndPassword } from "@firebase-ui/core"; +import type { UserCredential } from "firebase/auth"; +import { useSignInAuthFormSchema, useUI } from "~/hooks"; +import { form } from "~/components/form"; +import { Policies } from "~/components/policies"; +import { useCallback } from "react"; export type SignInAuthFormProps = { onSignIn?: (credential: UserCredential) => void; onForgotPasswordClick?: () => void; onRegisterClick?: () => void; -} +}; -export function SignInAuthForm({ onSignIn, onForgotPasswordClick, onRegisterClick }: SignInAuthFormProps) { +export function useSignInAuthFormAction() { const ui = useUI(); - const [formError, setFormError] = useState(null); - const [firstValidationOccured, setFirstValidationOccured] = useState(false); + return useCallback( + async ({ email, password }: { email: string; password: string }) => { + try { + return await signInWithEmailAndPassword(ui, email, password); + } catch (error) { + if (error instanceof FirebaseUIError) { + throw new Error(error.message); + } + + console.error(error); + throw new Error(getTranslation(ui, "errors", "unknownError")); + } + }, + [ui] + ); +} - // TODO: Do we need to memoize this? - const emailFormSchema = useMemo(() => createSignInAuthFormSchema(ui), [ui]); +export function useSignInAuthForm(onSuccess?: SignInAuthFormProps["onSignIn"]) { + const schema = useSignInAuthFormSchema(); + const action = useSignInAuthFormAction(); - const form = useForm({ + return form.useAppForm({ defaultValues: { email: "", password: "", }, validators: { - onBlur: emailFormSchema, - onSubmit: emailFormSchema, - }, - onSubmit: async ({ value }) => { - setFormError(null); - try { - const credential = await signInWithEmailAndPassword(ui, value.email, value.password); - onSignIn?.(credential); - } catch (error) { - if (error instanceof FirebaseUIError) { - setFormError(error.message); - return; + onBlur: schema, + onSubmit: schema, + onSubmitAsync: async ({ value }) => { + try { + const credential = await action(value); + return onSuccess?.(credential); + } catch (error) { + return error instanceof Error ? error.message : String(error); } - - console.error(error); - setFormError(getTranslation(ui, "errors", "unknownError")); - } + }, }, }); +} + +export function SignInAuthForm({ onSignIn, onForgotPasswordClick, onRegisterClick }: SignInAuthFormProps) { + const ui = useUI(); + const form = useSignInAuthForm(onSignIn); return (
-
- ( - <> - - - )} - /> -
- -
- ( - <> - - - )} - /> -
- - - -
- - {formError &&
{formError}
} -
- - {onRegisterClick && ( -
- -
- )} + + ) : null} + ); } diff --git a/packages/react/src/auth/forms/sign-up-auth-form.test.tsx b/packages/react/src/auth/forms/sign-up-auth-form.test.tsx index b48fab46..6b6c3119 100644 --- a/packages/react/src/auth/forms/sign-up-auth-form.test.tsx +++ b/packages/react/src/auth/forms/sign-up-auth-form.test.tsx @@ -14,217 +14,236 @@ * limitations under the License. */ -import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { SignUpAuthForm } from "./sign-up-auth-form"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, renderHook, cleanup } from "@testing-library/react"; +import { SignUpAuthForm, useSignUpAuthForm, useSignUpAuthFormAction } from "./sign-up-auth-form"; import { act } from "react"; +import { createUserWithEmailAndPassword } from "@firebase-ui/core"; +import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import type { UserCredential } from "firebase/auth"; +import { FirebaseUIProvider } from "~/context"; -// Mock the dependencies -vi.mock("@firebase-ui/core", async (originalImport) => { - const mod = await originalImport(); +vi.mock("@firebase-ui/core", async (importOriginal) => { + const mod = await importOriginal(); return { ...mod, - createUserWithEmailAndPassword: vi.fn().mockResolvedValue(undefined), + createUserWithEmailAndPassword: vi.fn(), }; }); -// Mock @tanstack/react-form library -vi.mock("@tanstack/react-form", () => { - const handleSubmitMock = vi.fn().mockImplementation((callback) => { - // Store the callback to call it directly in tests - (global as any).formSubmitCallback = callback; - return Promise.resolve(); - }); - +vi.mock("~/components/form", async (importOriginal) => { + const mod = await importOriginal(); return { - useForm: vi.fn().mockImplementation(({ onSubmit }) => { - // Save the onSubmit function to call it directly in tests - (global as any).formOnSubmit = onSubmit; - - return { - handleSubmit: handleSubmitMock, - Field: ({ children, name }: any) => { - const field = { - name, - state: { - value: name === "email" ? "test@example.com" : "password123", - meta: { - isTouched: false, - errors: [], - }, - }, - handleBlur: vi.fn(), - handleChange: vi.fn(), - }; - return children(field); - }, - }; - }), + ...mod, + form: { + ...mod.form, + ErrorMessage: () =>
Error Message
, + }, }; }); -vi.mock("../../../../src/hooks", () => ({ - useAuth: vi.fn().mockReturnValue({}), - useUI: vi.fn().mockReturnValue({ - locale: "en-US", - translations: { - "en-US": { - labels: { - emailAddress: "Email Address", - password: "Password", - }, - errors: { - unknownError: "Unknown error", - }, - }, - }, - }), -})); - -// Mock the components -vi.mock("../../../../src/components/field-info", () => ({ - FieldInfo: vi - .fn() - .mockImplementation(({ field }) => ( -
- {field.state.meta.errors.length > 0 && {field.state.meta.errors[0]}} -
- )), -})); - -vi.mock("../../../../src/components/policies", () => ({ - Policies: vi.fn().mockReturnValue(
), -})); - -vi.mock("../../../../src/components/button", () => ({ - Button: vi.fn().mockImplementation(({ children, type, onClick }) => ( - - )), -})); - -// Import the actual functions after mocking -import { createUserWithEmailAndPassword } from "@firebase-ui/core"; - -describe("RegisterForm", () => { +describe("useSignUpAuthFormAction", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("renders the form correctly", () => { - render(); + it("should return a callback which accept an email and password", async () => { + const createUserWithEmailAndPasswordMock = vi.mocked(createUserWithEmailAndPassword); + const mockUI = createMockUI(); + + const { result } = renderHook(() => useSignUpAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + await act(async () => { + await result.current({ email: "test@example.com", password: "password123" }); + }); - expect(screen.getByRole("textbox", { name: /email address/i })).toBeInTheDocument(); - expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); - expect(screen.getByTestId("policies")).toBeInTheDocument(); - expect(screen.getByTestId("submit-button")).toBeInTheDocument(); + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith(expect.any(Object), "test@example.com", "password123"); }); - it("submits the form when the button is clicked", async () => { - render(); + it("should return a credential on success", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; - // Get the submit button - const submitButton = screen.getByTestId("submit-button"); + const createUserWithEmailAndPasswordMock = vi.mocked(createUserWithEmailAndPassword).mockResolvedValue(mockCredential); + + const mockUI = createMockUI(); + + const { result } = renderHook(() => useSignUpAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); - // Trigger form submission await act(async () => { - fireEvent.click(submitButton); - - // Directly call the onSubmit function with form values - if ((global as any).formOnSubmit) { - await (global as any).formOnSubmit({ - value: { - email: "test@example.com", - password: "password123", - }, - }); - } + const credential = await result.current({ email: "test@example.com", password: "password123" }); + expect(credential).toBe(mockCredential); }); - // Check that the registration function was called - expect(createUserWithEmailAndPassword).toHaveBeenCalledWith(expect.anything(), "test@example.com", "password123"); + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith(expect.any(Object), "test@example.com", "password123"); }); - it("displays error message when registration fails", async () => { - // Mock the registration function to reject with an error - const mockError = new Error("Email already in use"); - (createUserWithEmailAndPassword as Mock).mockRejectedValueOnce(mockError); + it("should throw an unknown error when its not a FirebaseUIError", async () => { + const createUserWithEmailAndPasswordMock = vi + .mocked(createUserWithEmailAndPassword) + .mockRejectedValue(new Error("Unknown error")); - render(); - - // Get the submit button - const submitButton = screen.getByTestId("submit-button"); + const mockUI = createMockUI({ + locale: registerLocale("es-ES", { + errors: { + unknownError: "unknownError", + }, + }), + }); - // Trigger form submission - await act(async () => { - fireEvent.click(submitButton); - - // Directly call the onSubmit function with form values - if ((global as any).formOnSubmit) { - await (global as any) - .formOnSubmit({ - value: { - email: "test@example.com", - password: "password123", - }, - }) - .catch(() => { - // Catch the error here to prevent test from failing - }); - } + const { result } = renderHook(() => useSignUpAuthFormAction(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), }); - // Check that the registration function was called - expect(createUserWithEmailAndPassword).toHaveBeenCalled(); + await expect(async () => { + await act(async () => { + await result.current({ email: "test@example.com", password: "password123" }); + }); + }).rejects.toThrow("unknownError"); + + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com", "password123"); + }); +}); + +describe("useSignUpAuthForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); }); - it("validates on blur for the first time", async () => { - render(); + it("should allow the form to be submitted", async () => { + const mockUI = createMockUI(); + const createUserWithEmailAndPasswordMock = vi.mocked(createUserWithEmailAndPassword); - const emailInput = screen.getByRole("textbox", { name: /email address/i }); - const passwordInput = screen.getByDisplayValue("password123"); + const { result } = renderHook(() => useSignUpAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + result.current.setFieldValue("email", "test@example.com"); + result.current.setFieldValue("password", "password123"); + }); await act(async () => { - fireEvent.blur(emailInput); - fireEvent.blur(passwordInput); + await result.current.handleSubmit(); }); - // Check that handleBlur was called - expect((global as any).formOnSubmit).toBeDefined(); + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com", "password123"); }); - it("validates on input after first blur", async () => { - render(); + it("should not allow the form to be submitted if the form is invalid", async () => { + const mockUI = createMockUI(); + const createUserWithEmailAndPasswordMock = vi.mocked(createUserWithEmailAndPassword); - const emailInput = screen.getByRole("textbox", { name: /email address/i }); - const passwordInput = screen.getByDisplayValue("password123"); + const { result } = renderHook(() => useSignUpAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); - // First validation on blur - await act(async () => { - fireEvent.blur(emailInput); - fireEvent.blur(passwordInput); + act(() => { + result.current.setFieldValue("email", "123"); }); - // Then validation should happen on input await act(async () => { - fireEvent.input(emailInput, { target: { value: "test@example.com" } }); - fireEvent.input(passwordInput, { target: { value: "password123" } }); + await result.current.handleSubmit(); }); - // Check that handleBlur and form.update were called - expect((global as any).formOnSubmit).toBeDefined(); + expect(result.current.getFieldMeta("email")!.errors[0].length).toBeGreaterThan(0); + expect(createUserWithEmailAndPasswordMock).not.toHaveBeenCalled(); + }); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + createAccount: "createAccount", + }, + }), + }); + + const { container } = render( + + + + ); + + // There should be only one form + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + // Make sure we have an email and password input + expect(screen.getByRole("textbox", { name: /email/i })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /password/i })).toBeInTheDocument(); + + // Ensure the "Create Account" button is present and is a submit button + const createAccountButton = screen.getByRole("button", { name: "createAccount" }); + expect(createAccountButton).toBeInTheDocument(); + expect(createAccountButton).toHaveAttribute("type", "submit"); }); - // TODO: Fix this test - it.skip("displays back to sign in button when provided", () => { + it("should render the back to sign in button callback when onBackToSignInClick is provided", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + prompts: { + haveAccount: "foo", + }, + labels: { + signIn: "bar", + }, + }), + }); + const onBackToSignInClickMock = vi.fn(); - render(); - const backButton = document.querySelector(".fui-form__action")!; - expect(backButton).toBeInTheDocument(); + render( + + + + ); + + const name = "foo bar"; + + const backToSignInButton = screen.getByRole("button", { name }); + expect(backToSignInButton).toBeInTheDocument(); + expect(backToSignInButton).toHaveTextContent(name); + + // Make sure it's a button so it doesn't submit the form + expect(backToSignInButton).toHaveAttribute("type", "button"); - fireEvent.click(backButton); + fireEvent.click(backToSignInButton); expect(onBackToSignInClickMock).toHaveBeenCalled(); }); + + it('should trigger validation errors when the form is blurred', () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + const form = container.querySelector("form.fui-form"); + expect(form).toBeInTheDocument(); + + const input = screen.getByRole("textbox", { name: /email/i }); + + act(() => { + fireEvent.blur(input); + }); + + expect(screen.getByText("Please enter a valid email address")).toBeInTheDocument(); + }); }); diff --git a/packages/react/src/auth/forms/sign-up-auth-form.tsx b/packages/react/src/auth/forms/sign-up-auth-form.tsx index ba213493..1f496c63 100644 --- a/packages/react/src/auth/forms/sign-up-auth-form.tsx +++ b/packages/react/src/auth/forms/sign-up-auth-form.tsx @@ -16,58 +16,65 @@ "use client"; -import { - FirebaseUIError, - createSignUpAuthFormSchema, - createUserWithEmailAndPassword, - getTranslation, - type SignUpAuthFormSchema, -} from "@firebase-ui/core"; -import { useForm } from "@tanstack/react-form"; -import { useMemo, useState } from "react"; -import { useUI } from "~/hooks"; -import { Button } from "../../components/button"; -import { FieldInfo } from "../../components/field-info"; -import { Policies } from "../../components/policies"; -import { type UserCredential } from "firebase/auth"; +import { FirebaseUIError, getTranslation, createUserWithEmailAndPassword } from "@firebase-ui/core"; +import type { UserCredential } from "firebase/auth"; +import { useSignUpAuthFormSchema, useUI } from "~/hooks"; +import { form } from "~/components/form"; +import { Policies } from "~/components/policies"; +import { useCallback } from "react"; export type SignUpAuthFormProps = { onSignUp?: (credential: UserCredential) => void; onBackToSignInClick?: () => void; -} +}; -export function SignUpAuthForm({ onBackToSignInClick, onSignUp }: SignUpAuthFormProps) { +export function useSignUpAuthFormAction() { const ui = useUI(); - const [formError, setFormError] = useState(null); - const [firstValidationOccured, setFirstValidationOccured] = useState(false); - const emailFormSchema = useMemo(() => createSignUpAuthFormSchema(ui), [ui]); - - const form = useForm({ - defaultValues: { - email: "", - password: "", - }, - validators: { - onBlur: emailFormSchema, - onSubmit: emailFormSchema, - }, - onSubmit: async ({ value }) => { - setFormError(null); + return useCallback( + async ({ email, password }: { email: string; password: string }) => { try { - const credential = await createUserWithEmailAndPassword(ui, value.email, value.password); - onSignUp?.(credential); + return await createUserWithEmailAndPassword(ui, email, password); } catch (error) { if (error instanceof FirebaseUIError) { - setFormError(error.message); - return; + throw new Error(error.message); } console.error(error); - setFormError(getTranslation(ui, "errors", "unknownError")); + throw new Error(getTranslation(ui, "errors", "unknownError")); } }, + [ui] + ); +} + +export function useSignUpAuthForm(onSuccess?: SignUpAuthFormProps["onSignUp"]) { + const schema = useSignUpAuthFormSchema(); + const action = useSignUpAuthFormAction(); + + return form.useAppForm({ + defaultValues: { + email: "", + password: "", + }, + validators: { + onBlur: schema, + onSubmit: schema, + onSubmitAsync: async ({ value }) => { + try { + const credential = await action(value); + return onSuccess?.(credential); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }, }); +} + +export function SignUpAuthForm({ onBackToSignInClick, onSignUp }: SignUpAuthFormProps) { + const ui = useUI(); + const form = useSignUpAuthForm(onSignUp); return (
-
- ( - <> - - - )} - /> -
- -
- ( - <> - - - )} - /> -
- - - -
- - {formError &&
{formError}
} -
- - {onBackToSignInClick && ( -
- -
- )} + +
+ } /> +
+
+ } /> +
+ +
+ + {getTranslation(ui, "labels", "createAccount")} + + +
+ {onBackToSignInClick ? ( + + {getTranslation(ui, "prompts", "haveAccount")} {getTranslation(ui, "labels", "signIn")} + + ) : null} +
); } diff --git a/packages/react/src/auth/index.ts b/packages/react/src/auth/index.ts index 994084c1..0d613303 100644 --- a/packages/react/src/auth/index.ts +++ b/packages/react/src/auth/index.ts @@ -14,11 +14,16 @@ * limitations under the License. */ -export { EmailLinkAuthForm, type EmailLinkAuthFormProps } from "./forms/email-link-auth-form"; -export { ForgotPasswordAuthForm, type ForgotPasswordAuthFormProps } from "./forms/forgot-password-auth-form"; -export { PhoneAuthForm, type PhoneAuthFormProps } from "./forms/phone-auth-form"; -export { SignInAuthForm, type SignInAuthFormProps } from "./forms/sign-in-auth-form"; -export { SignUpAuthForm, type SignUpAuthFormProps } from "./forms/sign-up-auth-form"; +export { EmailLinkAuthForm, type EmailLinkAuthFormProps, useEmailLinkAuthFormAction, useEmailLinkAuthForm } from "./forms/email-link-auth-form"; +export { ForgotPasswordAuthForm, type ForgotPasswordAuthFormProps, useForgotPasswordAuthFormAction, useForgotPasswordAuthForm } from "./forms/forgot-password-auth-form"; +export { PhoneAuthForm, type PhoneAuthFormProps, usePhoneAuthFormAction, usePhoneVerificationFormAction, usePhoneResendAction } from "./forms/phone-auth-form"; +export { + SignInAuthForm, + type SignInAuthFormProps, + useSignInAuthForm, + useSignInAuthFormAction, +} from "./forms/sign-in-auth-form"; +export { SignUpAuthForm, type SignUpAuthFormProps, useSignUpAuthForm, useSignUpAuthFormAction } from "./forms/sign-up-auth-form"; export { EmailLinkAuthScreen, type EmailLinkAuthScreenProps } from "./screens/email-link-auth-screen"; export { ForgotPasswordAuthScreen, type ForgotPasswordAuthScreenProps } from "./screens/forgot-password-auth-screen"; @@ -28,4 +33,4 @@ export { SignInAuthScreen, type SignInAuthScreenProps } from "./screens/sign-in- export { SignUpAuthScreen, type SignUpAuthScreenProps } from "./screens/sign-up-auth-screen"; export { GoogleSignInButton, GoogleIcon, type GoogleSignInButtonProps } from "./oauth/google-sign-in-button"; -export { OAuthButton, type OAuthButtonProps } from "./oauth/oauth-button"; \ No newline at end of file +export { OAuthButton, type OAuthButtonProps } from "./oauth/oauth-button"; diff --git a/packages/react/src/auth/oauth/google-sign-in-button.test.tsx b/packages/react/src/auth/oauth/google-sign-in-button.test.tsx index c3739996..aad80bb6 100644 --- a/packages/react/src/auth/oauth/google-sign-in-button.test.tsx +++ b/packages/react/src/auth/oauth/google-sign-in-button.test.tsx @@ -4,7 +4,6 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software @@ -14,24 +13,17 @@ * limitations under the License. */ -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { GoogleIcon, GoogleSignInButton } from "~/auth/oauth/google-sign-in-button"; - -// Mock hooks -vi.mock("~/hooks", () => ({ - useUI: () => ({ - locale: "en-US", - translations: { - "en-US": { labels: { signInWithGoogle: "foo bar" } }, - }, - }), -})); +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { GoogleIcon, GoogleSignInButton } from "./google-sign-in-button"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { ComponentProps } from "react"; // Mock the OAuthButton component -vi.mock("~/auth/oauth/oauth-button", () => ({ - OAuthButton: ({ children, provider }: { children: React.ReactNode; provider: any }) => ( -
+vi.mock("./oauth-button", () => ({ + OAuthButton: ({ children, provider }: ComponentProps<"div"> & { provider: any }) => ( +
{children}
), @@ -46,30 +38,165 @@ vi.mock("firebase/auth", () => ({ }, })); -describe("GoogleSignInButton", () => { +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders with the correct provider", () => { - render(); - expect(screen.getByTestId("oauth-button")).toHaveAttribute("data-provider", "GoogleAuthProvider"); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + const oauthButton = screen.getByTestId("oauth-button"); + expect(oauthButton).toBeDefined(); + expect(oauthButton.getAttribute("data-provider")).toBe("GoogleAuthProvider"); }); - it("renders with the Google icon SVG", () => { - render(); + it("renders with custom provider when provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + const customProvider = new (class CustomGoogleProvider { + constructor() { + // Empty constructor + } + })(); + + render( + + + + ); + + const oauthButton = screen.getByTestId("oauth-button"); + expect(oauthButton).toBeDefined(); + expect(oauthButton.getAttribute("data-provider")).toBe("CustomGoogleProvider"); + }); + + it("renders with the Google icon", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + const svg = document.querySelector(".fui-provider__icon"); - expect(svg).toBeInTheDocument(); + expect(svg).toBeDefined(); expect(svg).toHaveClass("fui-provider__icon"); + expect(svg?.tagName.toLowerCase()).toBe("svg"); }); - it("renders with the correct text", () => { - render(); - expect(screen.getByText("foo bar")).toBeInTheDocument(); + it("renders with the correct translated text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Sign in with Google")).toBeDefined(); }); -}); -it("exports a valid GoogleIcon component which is an svg", () => { - const { container } = render(); - const svg = container.querySelector("svg"); - expect(svg).toBeInTheDocument(); - expect(svg?.tagName.toLowerCase()).toBe("svg"); - expect(svg).toHaveClass("fui-provider__icon"); + it("renders with different translated text for different locales", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Iniciar sesión con Google", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Iniciar sesión con Google")).toBeDefined(); + }); + + it("passes children to OAuthButton", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + const oauthButton = screen.getByTestId("oauth-button"); + expect(oauthButton).toBeDefined(); + + const svg = oauthButton.querySelector(".fui-provider__icon"); + const text = oauthButton.querySelector("span"); + + expect(svg).toBeDefined(); + expect(text).toBeDefined(); + expect(text?.textContent).toBe("Sign in with Google"); + }); }); +describe("", () => { + it("renders as an SVG element", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toBeDefined(); + expect(svg?.tagName.toLowerCase()).toBe("svg"); + }); + + it("has the correct CSS class", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg).toHaveClass("fui-provider__icon"); + }); + + it("has the correct viewBox attribute", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + + expect(svg?.getAttribute("viewBox")).toBe("0 0 48 48"); + }); +}); \ No newline at end of file diff --git a/packages/react/src/auth/oauth/oauth-button.test.tsx b/packages/react/src/auth/oauth/oauth-button.test.tsx index 1c15f153..c45182cc 100644 --- a/packages/react/src/auth/oauth/oauth-button.test.tsx +++ b/packages/react/src/auth/oauth/oauth-button.test.tsx @@ -4,7 +4,6 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software @@ -14,99 +13,227 @@ * limitations under the License. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { OAuthButton } from "../../../../src/auth/oauth/oauth-button"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { OAuthButton } from "./oauth-button"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; import type { AuthProvider } from "firebase/auth"; -import { signInWithOAuth } from "@firebase-ui/core"; +import { ComponentProps } from "react"; + +import { signInWithProvider } from "@firebase-ui/core"; -// Mock signInWithOAuth function -vi.mock("@firebase-ui/core", async (importOriginal) => { - const mod = await importOriginal(); +vi.mock('@firebase-ui/core', async (importOriginal) => { + const mod = await importOriginal(); return { - ...mod, - signInWithOAuth: vi.fn(), + ...(mod as object), + signInWithProvider: vi.fn(), + // TODO: This will need updating when core lands + FirebaseUIError: class FirebaseUIError extends Error { + code: string; + constructor(error: any, _ui: any) { + const errorCode = error?.code || "unknown"; + const message = errorCode === "auth/user-not-found" + ? "No account found with this email address" + : errorCode === "auth/wrong-password" + ? "The password is invalid or the user does not have a password" + : "An unexpected error occurred"; + super(message); + this.name = "FirebaseUIError"; + this.code = errorCode; + } + }, }; }); -// Create a mock provider that matches the AuthProvider interface -const mockGoogleProvider = { providerId: "google.com" } as AuthProvider; - -// Mock React hooks from the package -const useAuthMock = vi.fn(); +vi.mock("~/components/button", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + Button: (props: ComponentProps<"button">) => ( + + ), + } +}); -vi.mock("../../../../src/hooks", () => ({ - useAuth: () => useAuthMock(), - useUI: () => vi.fn(), -})); +afterEach(() => { + cleanup(); +}); -// Mock the Button component -vi.mock("../../../../src/components/button", () => ({ - Button: ({ children, onClick, disabled }: any) => ( - - ), -})); +describe("", () => { + const mockGoogleProvider = { providerId: "google.com" } as AuthProvider; -describe("OAuthButton Component", () => { beforeEach(() => { vi.clearAllMocks(); }); it("renders a button with the provided children", () => { - render(Sign in with Google); + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button).toBeDefined(); + expect(button.textContent).toBe("Sign in with Google"); + }); + + it("applies correct CSS classes", () => { + const ui = createMockUI(); + + render( + + Sign in with Google + + ); const button = screen.getByTestId("oauth-button"); - expect(button).toBeInTheDocument(); - expect(button).toHaveTextContent("Sign in with Google"); + expect(button).toHaveClass("fui-provider__button"); + expect(button.getAttribute("type")).toBe("button"); }); - // TODO: Fix this test - it.skip("calls signInWithOAuth when clicked", async () => { - // Mock the signInWithOAuth to resolve immediately - vi.mocked(signInWithOAuth).mockResolvedValueOnce(undefined); + it("is disabled when UI state is not idle", () => { + const ui = createMockUI(); + ui.setKey("state", "pending"); - render(Sign in with Google); + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button).toHaveAttribute("disabled"); + }); + + it("is enabled when UI state is idle", () => { + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button).not.toHaveAttribute("disabled"); + }); + + it("calls signInWithProvider when clicked", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + mockSignInWithProvider.mockResolvedValue(undefined); + + const ui = createMockUI(); + + render( + + Sign in with Google + + ); const button = screen.getByTestId("oauth-button"); fireEvent.click(button); - await waitFor(() => { - expect(signInWithOAuth).toHaveBeenCalledTimes(1); - expect(signInWithOAuth).toHaveBeenCalledWith(expect.anything(), mockGoogleProvider); - }); + expect(mockSignInWithProvider).toHaveBeenCalledTimes(1); + expect(mockSignInWithProvider).toHaveBeenCalledWith(expect.anything(), mockGoogleProvider); }); - // TODO: Fix this test - it.skip("displays error message when non-Firebase error occurs", async () => { + it("displays FirebaseUIError message when FirebaseUIError occurs", async () => { + const { FirebaseUIError } = await import("@firebase-ui/core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const ui = createMockUI(); + const mockError = new FirebaseUIError({ code: "auth/user-not-found" }, ui.get()); + mockSignInWithProvider.mockRejectedValue(mockError); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + // Wait for error to appear + await new Promise(resolve => setTimeout(resolve, 0)); + + // The error message will be the translated message for auth/user-not-found + const errorMessage = screen.getByText("No account found with this email address"); + expect(errorMessage).toBeDefined(); + expect(errorMessage.className).toContain("fui-form__error"); + }); + + it("displays unknown error message when non-Firebase error occurs", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const regularError = new Error("Regular error"); + mockSignInWithProvider.mockRejectedValue(regularError); + // Mock console.error to prevent test output noise const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - // Mock a non-Firebase error to trigger console.error - const regularError = new Error("Regular error"); - vi.mocked(signInWithOAuth).mockRejectedValueOnce(regularError); + const ui = createMockUI({ + locale: registerLocale("test", { + errors: { + unknownError: "unknownError", + }, + }), + }); - render(Sign in with Google); + render( + + Sign in with Google + + ); const button = screen.getByTestId("oauth-button"); - - // Click the button to trigger the error fireEvent.click(button); - // Wait for the error message to be displayed - await waitFor(() => { - // Verify console.error was called with the regular error - expect(consoleErrorSpy).toHaveBeenCalledWith(regularError); + // Wait for error to appear + await new Promise(resolve => setTimeout(resolve, 0)); - // Verify the error message is displayed - const errorMessage = screen.getByText("An unknown error occurred"); - expect(errorMessage).toBeInTheDocument(); - expect(errorMessage).toHaveClass("fui-form__error"); - }); + expect(consoleErrorSpy).toHaveBeenCalledWith(regularError); + + const errorMessage = screen.getByText("unknownError"); + expect(errorMessage).toBeDefined(); + expect(errorMessage.className).toContain("fui-form__error"); // Restore console.error consoleErrorSpy.mockRestore(); }); -}); + + it("clears error when button is clicked again", async () => { + const { FirebaseUIError } = await import("@firebase-ui/core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const ui = createMockUI(); + + // First call fails, second call succeeds + mockSignInWithProvider + .mockRejectedValueOnce(new FirebaseUIError({ code: "auth/wrong-password" }, ui.get())) + .mockResolvedValueOnce(undefined); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + + // First click - should show error + fireEvent.click(button); + await new Promise(resolve => setTimeout(resolve, 0)); + + // The error message will be the translated message for auth/wrong-password + const errorMessage = screen.getByText("The password is invalid or the user does not have a password"); + expect(errorMessage).toBeDefined(); + + // Second click - should clear error + fireEvent.click(button); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(screen.queryByText("The password is invalid or the user does not have a password")).toBeNull(); + }); +}); \ No newline at end of file diff --git a/packages/react/src/auth/oauth/oauth-button.tsx b/packages/react/src/auth/oauth/oauth-button.tsx index 592a8e1b..39dfd4f8 100644 --- a/packages/react/src/auth/oauth/oauth-button.tsx +++ b/packages/react/src/auth/oauth/oauth-button.tsx @@ -16,7 +16,7 @@ "use client"; -import { FirebaseUIError, getTranslation, signInWithOAuth } from "@firebase-ui/core"; +import { FirebaseUIError, getTranslation, signInWithProvider } from "@firebase-ui/core"; import type { AuthProvider } from "firebase/auth"; import type { PropsWithChildren } from "react"; import { useState } from "react"; @@ -35,7 +35,7 @@ export function OAuthButton({ provider, children }: OAuthButtonProps) { const handleOAuthSignIn = async () => { setError(null); try { - await signInWithOAuth(ui, provider); + await signInWithProvider(ui, provider); } catch (error) { if (error instanceof FirebaseUIError) { setError(error.message); diff --git a/packages/react/src/auth/screens/email-link-auth-screen.test.tsx b/packages/react/src/auth/screens/email-link-auth-screen.test.tsx index e021d4ff..da8cabf2 100644 --- a/packages/react/src/auth/screens/email-link-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/email-link-auth-screen.test.tsx @@ -15,79 +15,80 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render } from "@testing-library/react"; +import { render, screen, cleanup } from "@testing-library/react"; import { EmailLinkAuthScreen } from "~/auth/screens/email-link-auth-screen"; -import * as hooks from "~/hooks"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; -// Mock the hooks -vi.mock("~/hooks", () => ({ - useUI: vi.fn(() => ({ - locale: "en-US", - translations: { - "en-US": { - labels: { - signIn: "Sign In", - }, - prompts: { - signInToAccount: "Sign in to your account", - }, - messages: { - dividerOr: "or", - }, - }, - }, - })), +vi.mock("~/auth/forms/email-link-auth-form", () => ({ + EmailLinkAuthForm: () =>
Email Link Form
, })); -// Mock the EmailLinkForm component -vi.mock("~/auth/forms/email-link-form", () => ({ - EmailLinkForm: () =>
Email Link Form
, +vi.mock("~/components/divider", () => ({ + Divider: () =>
Divider
, })); -describe("EmailLinkAuthScreen", () => { +describe("", () => { beforeEach(() => { - // Setup default mock values - vi.mocked(hooks.useUI).mockReturnValue({ - locale: "en-US", - } as any); + vi.clearAllMocks(); }); afterEach(() => { - vi.clearAllMocks(); + cleanup(); }); it("renders with correct title and subtitle", () => { - const { getByText } = render(); - - expect(getByText("Sign In")).toBeInTheDocument(); - expect(getByText("Sign in to your account")).toBeInTheDocument(); - }); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "signIn", + }, + prompts: { + signInToAccount: "signInToAccount", + }, + }), + }); - it("calls useUI to get the locale", () => { - render(); - expect(hooks.useUI).toHaveBeenCalled(); - }); + render( + + + + ); - it("includes the EmailLinkForm component", () => { - const { getByTestId } = render(); + const title = screen.getByText("signIn"); + expect(title).toBeInTheDocument(); + expect(title).toHaveClass("fui-card__title"); - expect(getByTestId("email-link-form")).toBeInTheDocument(); + const subtitle = screen.getByText("signInToAccount"); + expect(subtitle).toBeInTheDocument(); + expect(subtitle).toHaveClass("fui-card__subtitle"); }); - it("does not render divider and children when no children are provided", () => { - const { queryByText } = render(); + it("renders the component", () => { + const ui = createMockUI(); + + render( + + + + ); - expect(queryByText("or")).not.toBeInTheDocument(); + // Mocked so only has as test id + expect(screen.getByTestId("email-link-auth-form")).toBeInTheDocument(); }); - it("renders divider and children when children are provided", () => { - const { getByText } = render( - -
Test Child
-
+ it("renders the a divider with children when present", () => { + const ui = createMockUI(); + + render( + + +
Test Child
+
+
); - expect(getByText("or")).toBeInTheDocument(); - expect(getByText("Test Child")).toBeInTheDocument(); + expect(screen.getByTestId("divider")).toBeInTheDocument(); + expect(screen.getByTestId("test-child")).toBeInTheDocument(); }); }); diff --git a/packages/react/src/auth/screens/forgot-password-auth-screen.test.tsx b/packages/react/src/auth/screens/forgot-password-auth-screen.test.tsx index 78206f47..53b77daf 100644 --- a/packages/react/src/auth/screens/forgot-password-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/forgot-password-auth-screen.test.tsx @@ -4,7 +4,6 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software @@ -15,31 +14,14 @@ */ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, fireEvent } from "@testing-library/react"; -import { PasswordResetScreen } from "~/auth/screens/forgot-password-auth-screen"; -import * as hooks from "~/hooks"; - -// Mock the hooks -vi.mock("~/hooks", () => ({ - useUI: vi.fn(() => ({ - locale: "en-US", - translations: { - "en-US": { - labels: { - resetPassword: "Reset Password", - }, - prompts: { - enterEmailToReset: "Enter your email to reset your password", - }, - }, - }, - })), -})); +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { ForgotPasswordAuthScreen } from "~/auth/screens/forgot-password-auth-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; -// Mock the ForgotPasswordForm component -vi.mock("~/auth/forms/forgot-password-form", () => ({ - ForgotPasswordForm: ({ onBackToSignInClick }: { onBackToSignInClick?: () => void }) => ( -
+vi.mock("~/auth/forms/forgot-password-auth-form", () => ({ + ForgotPasswordAuthForm: ({ onBackToSignInClick }: { onBackToSignInClick?: () => void }) => ( +
@@ -47,37 +29,67 @@ vi.mock("~/auth/forms/forgot-password-form", () => ({ ), })); -describe("PasswordResetScreen", () => { - const mockOnBackToSignInClick = vi.fn(); +describe("", () => { + afterEach(() => { + cleanup(); + }); afterEach(() => { vi.clearAllMocks(); }); it("renders with correct title and subtitle", () => { - const { getByText } = render(); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + resetPassword: "resetPassword", + }, + prompts: { + enterEmailToReset: "enterEmailToReset", + }, + }), + }); - expect(getByText("Reset Password")).toBeInTheDocument(); - expect(getByText("Enter your email to reset your password")).toBeInTheDocument(); - }); + render( + + + + ); - it("calls useUI to get the locale", () => { - render(); + const title = screen.getByText("resetPassword"); + expect(title).toBeDefined(); + expect(title.className).toContain("fui-card__title"); - expect(hooks.useUI).toHaveBeenCalled(); + const subtitle = screen.getByText("enterEmailToReset"); + expect(subtitle).toBeDefined(); + expect(subtitle.className).toContain("fui-card__subtitle"); }); - it("includes the ForgotPasswordForm component", () => { - const { getByTestId } = render(); + it("renders the component", () => { + const ui = createMockUI(); - expect(getByTestId("forgot-password-form")).toBeInTheDocument(); + render( + + + + ); + + // Mocked so only has as test id + expect(screen.getByTestId("forgot-password-auth-form")).toBeDefined(); }); - it("passes onBackToSignInClick to ForgotPasswordForm", () => { - const { getByTestId } = render(); + it("passes onBackToSignInClick to ForgotPasswordAuthForm", () => { + const mockOnBackToSignInClick = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); // Click the back button in the mocked form - fireEvent.click(getByTestId("back-button")); + fireEvent.click(screen.getByTestId("back-button")); // Verify the callback was called expect(mockOnBackToSignInClick).toHaveBeenCalledTimes(1); diff --git a/packages/react/src/auth/screens/oauth-screen.test.tsx b/packages/react/src/auth/screens/oauth-screen.test.tsx index 0973269b..2ddae387 100644 --- a/packages/react/src/auth/screens/oauth-screen.test.tsx +++ b/packages/react/src/auth/screens/oauth-screen.test.tsx @@ -4,7 +4,6 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software @@ -14,76 +13,120 @@ * limitations under the License. */ -import { describe, it, expect, vi } from "vitest"; -import { render } from "@testing-library/react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; import { OAuthScreen } from "~/auth/screens/oauth-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; + +vi.mock("~/components/policies", async (originalModule) => { + const module = await originalModule(); + return { + ...(module as object), + Policies: () =>
Policies
, + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + afterEach(() => { + vi.clearAllMocks(); + }); -// Mock hooks -vi.mock("~/hooks", () => ({ - useUI: () => ({ - locale: "en-US", - translations: { - "en-US": { + it("renders with correct title and subtitle", () => { + const ui = createMockUI({ + locale: registerLocale("test", { labels: { - signIn: "Sign In", - signInToAccount: "Sign in to your account", + signIn: "signIn", }, - }, - }, - }), -})); - -// Mock getTranslation -// vi.mock("@firebase-ui/core", () => ({ -// getTranslation: vi.fn((category, key) => { -// if (category === "labels" && key === "signIn") return "Sign In"; -// if (category === "prompts" && key === "signInToAccount") -// return "Sign in to your account"; -// return key; -// }), -// })); - -// Mock TermsAndPrivacy component -vi.mock("../../../../src/components/policies", () => ({ - Policies: () =>
Policies
, -})); - -describe("OAuthScreen", () => { - it("renders with correct title and subtitle", () => { - const { getByText } = render(OAuth Provider); + prompts: { + signInToAccount: "signInToAccount", + }, + }), + }); - expect(getByText("Sign In")).toBeInTheDocument(); - expect(getByText("Sign in to your account")).toBeInTheDocument(); - }); + render( + + OAuth Provider + + ); - it("calls useConfig to get the language", () => { - render(OAuth Provider); + const title = screen.getByText("signIn"); + expect(title).toBeDefined(); + expect(title.className).toContain("fui-card__title"); - // This test implicitly tests that useConfig is called through the mock - // If it hadn't been called, the title and subtitle wouldn't render correctly + const subtitle = screen.getByText("signInToAccount"); + expect(subtitle).toBeDefined(); + expect(subtitle.className).toContain("fui-card__subtitle"); }); it("renders children", () => { - const { getByText } = render(OAuth Provider); + const ui = createMockUI(); + + render( + + OAuth Provider + + ); - expect(getByText("OAuth Provider")).toBeInTheDocument(); + expect(screen.getByText("OAuth Provider")).toBeDefined(); }); it("renders multiple children when provided", () => { - const { getByText } = render( - -
Provider 1
-
Provider 2
-
+ const ui = createMockUI(); + + render( + + +
Provider 1
+
Provider 2
+
+
); - expect(getByText("Provider 1")).toBeInTheDocument(); - expect(getByText("Provider 2")).toBeInTheDocument(); + expect(screen.getByText("Provider 1")).toBeDefined(); + expect(screen.getByText("Provider 2")).toBeDefined(); }); it("includes the Policies component", () => { - const { getByTestId } = render(OAuth Provider); + const ui = createMockUI(); + + render( + + OAuth Provider + + ); - expect(getByTestId("policies")).toBeInTheDocument(); + expect(screen.getByTestId("policies")).toBeDefined(); }); -}); + + it("renders children before the Policies component", () => { + const ui = createMockUI(); + + render( + + +
OAuth Provider
+
+
+ ); + + const oauthProvider = screen.getByTestId("oauth-provider"); + const policies = screen.getByTestId("policies"); + + // Both should be present + expect(oauthProvider).toBeDefined(); + expect(policies).toBeDefined(); + + // OAuth provider should come before policies in the DOM + const cardContent = oauthProvider.parentElement; + const children = Array.from(cardContent?.children || []); + const oauthIndex = children.indexOf(oauthProvider); + const policiesIndex = children.indexOf(policies); + + expect(oauthIndex).toBeLessThan(policiesIndex); + }); +}); \ No newline at end of file diff --git a/packages/react/src/auth/screens/phone-auth-screen.test.tsx b/packages/react/src/auth/screens/phone-auth-screen.test.tsx index 18f18aa4..b257ae71 100644 --- a/packages/react/src/auth/screens/phone-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/phone-auth-screen.test.tsx @@ -4,7 +4,6 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software @@ -14,73 +13,147 @@ * limitations under the License. */ -import { describe, it, expect, vi } from "vitest"; -import { render } from "@testing-library/react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; import { PhoneAuthScreen } from "~/auth/screens/phone-auth-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; + +vi.mock("~/auth/forms/phone-auth-form", () => ({ + PhoneAuthForm: ({ resendDelay }: { resendDelay?: number }) => ( +
+ Phone Auth Form +
+ ), +})); + +vi.mock("~/components/divider", async (originalModule) => { + const module = await originalModule(); + return { + ...(module as object), + Divider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + afterEach(() => { + vi.clearAllMocks(); + }); -// Mock the hooks -vi.mock("~/hooks", () => ({ - useUI: () => ({ - locale: "en-US", - translations: { - "en-US": { + it("renders with correct title and subtitle", () => { + const ui = createMockUI({ + locale: registerLocale("test", { labels: { - signIn: "Sign in", - dividerOr: "or", + signIn: "signIn", }, prompts: { - signInToAccount: "Sign in to your account", + signInToAccount: "signInToAccount", }, - }, - }, - }), -})); + }), + }); -// Mock the PhoneForm component -vi.mock("~/auth/forms/phone-form", () => ({ - PhoneForm: ({ resendDelay }: { resendDelay?: number }) => ( -
- Phone Form -
- ), -})); + render( + + + + ); -describe("PhoneAuthScreen", () => { - it("displays the correct title and subtitle", () => { - const { getByText } = render(); + const title = screen.getByText("signIn"); + expect(title).toBeDefined(); + expect(title.className).toContain("fui-card__title"); - expect(getByText("Sign in")).toBeInTheDocument(); - expect(getByText("Sign in to your account")).toBeInTheDocument(); + const subtitle = screen.getByText("signInToAccount"); + expect(subtitle).toBeDefined(); + expect(subtitle.className).toContain("fui-card__subtitle"); }); - it("calls useConfig to retrieve the language", () => { - const { getByText } = render(); + it("renders the component", () => { + const ui = createMockUI(); - expect(getByText("Sign in")).toBeInTheDocument(); + render( + + + + ); + + // Mocked so only has as test id + expect(screen.getByTestId("phone-auth-form")).toBeDefined(); }); - it("includes the PhoneForm with the correct resendDelay prop", () => { - const { getByTestId } = render(); + it("passes resendDelay prop to PhoneAuthForm", () => { + const ui = createMockUI(); + + render( + + + + ); - const phoneForm = getByTestId("phone-form"); - expect(phoneForm).toBeInTheDocument(); + const phoneForm = screen.getByTestId("phone-auth-form"); + expect(phoneForm).toBeDefined(); expect(phoneForm.getAttribute("data-resend-delay")).toBe("60"); }); - it("renders children when provided", () => { - const { getByText, getByTestId } = render( - - - + it("renders a divider with children when present", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Test Child
+
+
); - expect(getByTestId("test-button")).toBeInTheDocument(); - expect(getByText("or")).toBeInTheDocument(); + expect(screen.getByTestId("divider")).toBeDefined(); + expect(screen.getByText("dividerOr")).toBeDefined(); + expect(screen.getByTestId("test-child")).toBeDefined(); }); - it("does not render children or divider when not provided", () => { - const { queryByText } = render(); + it("does not render divider and children when no children are provided", () => { + const ui = createMockUI(); + + render( + + + + ); - expect(queryByText("or")).not.toBeInTheDocument(); + expect(screen.queryByTestId("divider")).toBeNull(); }); -}); + + it("renders multiple children when provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Child 1
+
Child 2
+
+
+ ); + + expect(screen.getByTestId("divider")).toBeDefined(); + expect(screen.getByTestId("child-1")).toBeDefined(); + expect(screen.getByTestId("child-2")).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx b/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx index 47a486ab..2ab83a2c 100644 --- a/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx @@ -4,7 +4,6 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software @@ -14,38 +13,21 @@ * limitations under the License. */ -import { describe, it, expect, vi } from "vitest"; -import { render, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { SignInAuthScreen } from "~/auth/screens/sign-in-auth-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; -// Mock the hooks -vi.mock("~/hooks", () => ({ - useUI: () => ({ - locale: "en-US", - translations: { - "en-US": { - labels: { - signIn: "Sign in", - dividerOr: "or", - }, - prompts: { - signInToAccount: "Sign in to your account", - }, - }, - }, - }), -})); - -// Mock the EmailPasswordForm component -vi.mock("~/auth/forms/email-password-form", () => ({ - EmailPasswordForm: ({ +vi.mock("~/auth/forms/sign-in-auth-form", () => ({ + SignInAuthForm: ({ onForgotPasswordClick, onRegisterClick, }: { onForgotPasswordClick?: () => void; onRegisterClick?: () => void; }) => ( -
+
@@ -56,60 +38,151 @@ vi.mock("~/auth/forms/email-password-form", () => ({ ), })); -describe("SignInAuthScreen", () => { - it("displays the correct title and subtitle", () => { - const { getByText } = render(); +vi.mock("~/components/divider", async (originalModule) => { + const module = await originalModule(); + return { + ...(module as object), + Divider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + }; +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - expect(getByText("Sign in")).toBeInTheDocument(); - expect(getByText("Sign in to your account")).toBeInTheDocument(); + afterEach(() => { + cleanup(); }); - it("calls useConfig to retrieve the language", () => { - const { getByText } = render(); + it("renders with correct title and subtitle", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "signIn", + }, + prompts: { + signInToAccount: "signInToAccount", + }, + }), + }); + + render( + + + + ); + + const title = screen.getByText("signIn"); + expect(title).toBeDefined(); + expect(title.className).toContain("fui-card__title"); - expect(getByText("Sign in")).toBeInTheDocument(); + const subtitle = screen.getByText("signInToAccount"); + expect(subtitle).toBeDefined(); + expect(subtitle.className).toContain("fui-card__subtitle"); }); - it("includes the EmailPasswordForm component", () => { - const { getByTestId } = render(); + it("renders the component", () => { + const ui = createMockUI(); + + render( + + + + ); - expect(getByTestId("email-password-form")).toBeInTheDocument(); + // Mocked so only has as test id + expect(screen.getByTestId("sign-in-auth-form")).toBeDefined(); }); - it("passes onForgotPasswordClick to EmailPasswordForm", () => { + it("passes onForgotPasswordClick to SignInAuthForm", () => { const mockOnForgotPasswordClick = vi.fn(); - const { getByTestId } = render(); + const ui = createMockUI(); + + render( + + + + ); - const forgotPasswordButton = getByTestId("forgot-password-button"); + const forgotPasswordButton = screen.getByTestId("forgot-password-button"); fireEvent.click(forgotPasswordButton); expect(mockOnForgotPasswordClick).toHaveBeenCalledTimes(1); }); - it("passes onRegisterClick to EmailPasswordForm", () => { + it("passes onRegisterClick to SignInAuthForm", () => { const mockOnRegisterClick = vi.fn(); - const { getByTestId } = render(); + const ui = createMockUI(); + + render( + + + + ); - const registerButton = getByTestId("register-button"); + const registerButton = screen.getByTestId("register-button"); fireEvent.click(registerButton); expect(mockOnRegisterClick).toHaveBeenCalledTimes(1); }); - it("renders children when provided", () => { - const { getByText, getByTestId } = render( - - - + it("renders a divider with children when present", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Test Child
+
+
+ ); + + expect(screen.getByTestId("divider")).toBeDefined(); + expect(screen.getByText("dividerOr")).toBeDefined(); + expect(screen.getByTestId("test-child")).toBeDefined(); + }); + + it("does not render divider and children when no children are provided", () => { + const ui = createMockUI(); + + render( + + + ); - expect(getByTestId("test-button")).toBeInTheDocument(); - expect(getByText("or")).toBeInTheDocument(); + expect(screen.queryByTestId("divider")).toBeNull(); }); - it("does not render children or divider when not provided", () => { - const { queryByText } = render(); + it("renders multiple children when provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); + + render( + + +
Child 1
+
Child 2
+
+
+ ); - expect(queryByText("or")).not.toBeInTheDocument(); + expect(screen.getByTestId("divider")).toBeDefined(); + expect(screen.getByTestId("child-1")).toBeDefined(); + expect(screen.getByTestId("child-2")).toBeDefined(); }); -}); +}); \ No newline at end of file diff --git a/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx b/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx index e7a4b2e6..9fcb4679 100644 --- a/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/sign-up-auth-screen.test.tsx @@ -4,7 +4,6 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software @@ -14,43 +13,15 @@ * limitations under the License. */ -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { SignUpAuthScreen } from "~/auth/screens/sign-up-auth-screen"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; -// Mock hooks -vi.mock("~/hooks", () => ({ - useUI: () => ({ - locale: "en-US", - translations: { - "en-US": { - labels: { - register: "Create Account", - dividerOr: "OR", - }, - prompts: { - enterDetailsToCreate: "Enter your details to create an account", - }, - }, - }, - }), -})); - -// Mock translations -// vi.mock("@firebase-ui/core", () => ({ -// getTranslation: vi.fn((category, key) => { -// if (category === "labels" && key === "register") return "Create Account"; -// if (category === "prompts" && key === "enterDetailsToCreate") -// return "Enter your details to create an account"; -// if (category === "messages" && key === "dividerOr") return "OR"; -// return `${category}.${key}`; -// }), -// })); - -// Mock RegisterForm component -vi.mock("~/auth/forms/register-form", () => ({ - RegisterForm: ({ onBackToSignInClick }: { onBackToSignInClick?: () => void }) => ( -
+vi.mock("~/auth/forms/sign-up-auth-form", () => ({ + SignUpAuthForm: ({ onBackToSignInClick }: { onBackToSignInClick?: () => void }) => ( +
@@ -58,44 +29,135 @@ vi.mock("~/auth/forms/register-form", () => ({ ), })); -describe("SignUpAuthScreen", () => { - it("renders the correct title and subtitle", () => { - render(); +vi.mock("~/components/divider", async (originalModule) => { + const module = await originalModule(); + return { + ...(module as object), + Divider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + }; +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders with correct title and subtitle", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + register: "register", + }, + prompts: { + enterDetailsToCreate: "enterDetailsToCreate", + }, + }), + }); + + render( + + + + ); + + const title = screen.getByText("register"); + expect(title).toBeDefined(); + expect(title.className).toContain("fui-card__title"); - expect(screen.getByText("Create Account")).toBeInTheDocument(); - expect(screen.getByText("Enter your details to create an account")).toBeInTheDocument(); + const subtitle = screen.getByText("enterDetailsToCreate"); + expect(subtitle).toBeDefined(); + expect(subtitle.className).toContain("fui-card__subtitle"); }); - it("includes the RegisterForm component", () => { - render(); + it("renders the component", () => { + const ui = createMockUI(); - expect(screen.getByTestId("register-form")).toBeInTheDocument(); + render( + + + + ); + + // Mocked so only has as test id + expect(screen.getByTestId("sign-up-auth-form")).toBeDefined(); }); - it("passes the onBackToSignInClick prop to the RegisterForm", async () => { - const onBackToSignInClick = vi.fn(); - render(); + it("passes onBackToSignInClick to SignUpAuthForm", () => { + const mockOnBackToSignInClick = vi.fn(); + const ui = createMockUI(); + + render( + + + + ); const backButton = screen.getByTestId("back-to-sign-in-button"); - backButton.click(); + fireEvent.click(backButton); + + expect(mockOnBackToSignInClick).toHaveBeenCalledTimes(1); + }); + + it("renders a divider with children when present", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); - expect(onBackToSignInClick).toHaveBeenCalled(); + render( + + +
Test Child
+
+
+ ); + + expect(screen.getByTestId("divider")).toBeDefined(); + expect(screen.getByText("dividerOr")).toBeDefined(); + expect(screen.getByTestId("test-child")).toBeDefined(); }); - it("renders children when provided", () => { + it("does not render divider and children when no children are provided", () => { + const ui = createMockUI(); + render( - -
Child element
-
+ + + ); - expect(screen.getByTestId("test-child")).toBeInTheDocument(); - expect(screen.getByText("or")).toBeInTheDocument(); + expect(screen.queryByTestId("divider")).toBeNull(); }); - it("does not render divider or children container when no children are provided", () => { - render(); + it("renders multiple children when provided", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + messages: { + dividerOr: "dividerOr", + }, + }), + }); - expect(screen.queryByText("or")).not.toBeInTheDocument(); + render( + + +
Child 1
+
Child 2
+
+
+ ); + + expect(screen.getByTestId("divider")).toBeDefined(); + expect(screen.getByTestId("child-1")).toBeDefined(); + expect(screen.getByTestId("child-2")).toBeDefined(); }); -}); +}); \ No newline at end of file diff --git a/packages/react/src/components/button.test.tsx b/packages/react/src/components/button.test.tsx index 24a0db56..4ffafad2 100644 --- a/packages/react/src/components/button.test.tsx +++ b/packages/react/src/components/button.test.tsx @@ -14,16 +14,19 @@ * limitations under the License. */ -import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import "@testing-library/jest-dom"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { Button } from "./button"; -describe("Button Component", () => { +afterEach(() => { + cleanup(); +}); + +describe("); const button = screen.getByRole("button", { name: /click me/i }); - expect(button).toBeInTheDocument(); + expect(button).toBeDefined(); expect(button).toHaveClass("fui-button"); expect(button).not.toHaveClass("fui-button--secondary"); }); @@ -60,7 +63,7 @@ describe("Button Component", () => { ); const button = screen.getByTestId("test-button"); - expect(button).toBeDisabled(); + expect(button).toHaveAttribute("disabled"); }); it("renders as a Slot component when asChild is true", () => { @@ -71,7 +74,7 @@ describe("Button Component", () => { ); const link = screen.getByRole("link", { name: /link button/i }); - expect(link).toBeInTheDocument(); + expect(link).toBeDefined(); expect(link).toHaveClass("fui-button"); expect(link.tagName).toBe("A"); expect(link).toHaveAttribute("href", "/test"); diff --git a/packages/react/src/components/card.test.tsx b/packages/react/src/components/card.test.tsx index 5053e278..44f00574 100644 --- a/packages/react/src/components/card.test.tsx +++ b/packages/react/src/components/card.test.tsx @@ -14,55 +14,89 @@ * limitations under the License. */ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { Card, CardHeader, CardTitle, CardSubtitle } from "./card"; +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { Card, CardHeader, CardTitle, CardSubtitle, CardContent } from "./card"; -describe("Card Components", () => { - describe("Card", () => { - it("renders a card with children", () => { - render(Card content); - const card = screen.getByTestId("test-card"); +afterEach(() => { + cleanup(); +}); - expect(card).toHaveClass("fui-card"); - expect(card).toHaveTextContent("Card content"); - }); +describe("", () => { + it("renders a card with children", () => { + render(Card content); + const card = screen.getByTestId("test-card"); - it("applies custom className", () => { - render( - - Card content - - ); - const card = screen.getByTestId("test-card"); + expect(card).toHaveClass("fui-card"); + expect(card).toHaveTextContent("Card content"); + }); - expect(card).toHaveClass("fui-card"); - expect(card).toHaveClass("custom-class"); - }); + it("applies custom className", () => { + render( + + Card content + + ); + const card = screen.getByTestId("test-card"); - it("passes other props to the div element", () => { - render( - - Card content - - ); - const card = screen.getByTestId("test-card"); + expect(card).toHaveClass("fui-card"); + expect(card).toHaveClass("custom-class"); + }); - expect(card).toHaveClass("fui-card"); - expect(card).toHaveAttribute("aria-label", "card"); - }); + it("passes other props to the div element", () => { + render( + + Card content + + ); + const card = screen.getByTestId("test-card"); + + expect(card).toHaveClass("fui-card"); + expect(card).toHaveAttribute("aria-label", "card"); }); - describe("CardHeader", () => { + it("renders a complete card with all subcomponents", () => { + render( + + + Card Title + Card Subtitle + + +
Card Body Content
+
+
+ ); + + const card = screen.getByTestId("complete-card"); + const header = screen.getByTestId("complete-header"); + const title = screen.getByRole("heading", { name: "Card Title" }); + const subtitle = screen.getByText("Card Subtitle"); + const content = screen.getByText("Card Body Content"); + + expect(card).toHaveClass("fui-card"); + expect(title).toHaveClass("fui-card__title"); + expect(subtitle).toHaveClass("fui-card__subtitle"); + expect(header).toHaveClass("fui-card__header"); + expect(content).toBeInTheDocument(); + + // Check structure + expect(header).toContainElement(title); + expect(header).toContainElement(subtitle); + expect(card).toContainElement(header); + expect(card).toContainElement(content); + }); + + + describe("", () => { it("renders a card header with children", () => { render(Header content); const header = screen.getByTestId("test-header"); - + expect(header).toHaveClass("fui-card__header"); expect(header).toHaveTextContent("Header content"); }); - + it("applies custom className", () => { render( @@ -70,75 +104,63 @@ describe("Card Components", () => { ); const header = screen.getByTestId("test-header"); - + expect(header).toHaveClass("fui-card__header"); expect(header).toHaveClass("custom-header"); }); }); - - describe("CardTitle", () => { + + describe("", () => { it("renders a card title with children", () => { render(Title content); const title = screen.getByRole("heading", { name: "Title content" }); - - expect(title).toHaveClass("fui-card__title"); + + expect(title.className).toContain("fui-card__title"); expect(title.tagName).toBe("H2"); }); - + it("applies custom className", () => { render(Title content); const title = screen.getByRole("heading", { name: "Title content" }); - + expect(title).toHaveClass("fui-card__title"); expect(title).toHaveClass("custom-title"); }); }); - - describe("CardSubtitle", () => { + + describe("", () => { it("renders a card subtitle with children", () => { render(Subtitle content); const subtitle = screen.getByText("Subtitle content"); - + expect(subtitle).toHaveClass("fui-card__subtitle"); expect(subtitle.tagName).toBe("P"); }); - + it("applies custom className", () => { render(Subtitle content); const subtitle = screen.getByText("Subtitle content"); - + expect(subtitle).toHaveClass("fui-card__subtitle"); expect(subtitle).toHaveClass("custom-subtitle"); }); }); - - it("renders a complete card with all subcomponents", () => { - render( - - - Card Title - Card Subtitle - -
Card Body Content
-
- ); - - const card = screen.getByTestId("complete-card"); - const header = screen.getByTestId("complete-header"); - const title = screen.getByRole("heading", { name: "Card Title" }); - const subtitle = screen.getByText("Card Subtitle"); - const content = screen.getByText("Card Body Content"); - - expect(card).toHaveClass("fui-card"); - expect(title).toHaveClass("fui-card__title"); - expect(subtitle).toHaveClass("fui-card__subtitle"); - expect(header).toHaveClass("fui-card__header"); - expect(content).toBeInTheDocument(); - - // Check structure - expect(header).toContainElement(title); - expect(header).toContainElement(subtitle); - expect(card).toContainElement(header); - expect(card).toContainElement(content); + + describe("", () => { + it("renders a card content with children", () => { + render(Content content); + const content = screen.getByText("Content content"); + + expect(content).toHaveClass("fui-card__content"); + expect(content.tagName).toBe("DIV"); + }); + + it("applies custom className", () => { + render(Content); + const content = screen.getByText("Content"); + + expect(content).toHaveClass("fui-card__content"); + expect(content).toHaveClass("custom-content"); + }); }); }); diff --git a/packages/react/src/components/card.tsx b/packages/react/src/components/card.tsx index 981abfae..c382af90 100644 --- a/packages/react/src/components/card.tsx +++ b/packages/react/src/components/card.tsx @@ -53,7 +53,7 @@ export function CardSubtitle({ children, className, ...props }: ComponentProps<" export function CardContent({ children, className, ...props }: ComponentProps<"div">) { return ( -
+
{children}
); diff --git a/packages/react/src/components/country-selector.test.tsx b/packages/react/src/components/country-selector.test.tsx index 7eea3647..dffe2ae3 100644 --- a/packages/react/src/components/country-selector.test.tsx +++ b/packages/react/src/components/country-selector.test.tsx @@ -14,81 +14,70 @@ * limitations under the License. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { CountrySelector } from "./country-selector"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { countryData } from "@firebase-ui/core"; +import { CountrySelector } from "./country-selector"; -describe("CountrySelector Component", () => { - const mockOnChange = vi.fn(); - const defaultCountry = countryData[0]; // First country in the list - +describe("", () => { beforeEach(() => { - mockOnChange.mockClear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); }); it("renders with the selected country", () => { - render(); + const country = countryData[0]; - // Check if the country flag emoji is displayed - expect(screen.getByText(defaultCountry.emoji)).toBeInTheDocument(); + render( {}} />); - // Check if the dial code is displayed - expect(screen.getByText(defaultCountry.dialCode)).toBeInTheDocument(); + expect(screen.getByText(country.emoji)).toBeInTheDocument(); + expect(screen.getByText(country.dialCode)).toBeInTheDocument(); - // Check if the select has the correct value const select = screen.getByRole("combobox"); - expect(select).toHaveValue(defaultCountry.code); + expect(select).toHaveValue(country.code); }); it("applies custom className", () => { - render(); + const country = countryData[0]; + render( {}} className="custom-class" />); - const selector = screen.getByRole("combobox").closest(".fui-country-selector"); - expect(selector).toHaveClass("fui-country-selector"); - expect(selector).toHaveClass("custom-class"); + const rootDiv = screen.getByRole("combobox").closest("div.fui-country-selector"); + expect(rootDiv).toHaveClass("custom-class"); }); it("calls onChange when a different country is selected", () => { - render(); + const country = countryData[0]; + const onChangeMock = vi.fn(); + + render(); const select = screen.getByRole("combobox"); // Find a different country to select - const newCountry = countryData.find((country) => country.code !== defaultCountry.code); + const newCountry = countryData.find(($) => $.code !== country.code); - if (newCountry) { - // Change the selection - fireEvent.change(select, { target: { value: newCountry.code } }); - - // Check if onChange was called with the new country - expect(mockOnChange).toHaveBeenCalledTimes(1); - expect(mockOnChange).toHaveBeenCalledWith(newCountry); - } else { - // Fail the test if no different country is found + if (!newCountry) { expect.fail("No different country found in countryData. Test cannot proceed."); } + + // Change the selection + fireEvent.change(select, { target: { value: newCountry.code } }); + + // Check if onChange was called with the new country + expect(onChangeMock).toHaveBeenCalledTimes(1); + expect(onChangeMock).toHaveBeenCalledWith(newCountry.code); }); it("renders all countries in the dropdown", () => { - render(); + const country = countryData[0]; + render( {}} />); const select = screen.getByRole("combobox"); const options = select.querySelectorAll("option"); - // Check if all countries are in the dropdown expect(options.length).toBe(countryData.length); - - // Check if a specific country exists in the dropdown - const usCountry = countryData.find((country) => country.code === "US"); - if (usCountry) { - const usOption = Array.from(options).find((option) => option.value === usCountry.code); - expect(usOption).toBeInTheDocument(); - expect(usOption?.textContent).toBe(`${usCountry.dialCode} (${usCountry.name})`); - } else { - // Fail the test if US country is not found - expect.fail("US country not found in countryData. Test cannot proceed."); - } }); }); diff --git a/packages/react/src/components/divider.test.tsx b/packages/react/src/components/divider.test.tsx index 92d041ee..4e744244 100644 --- a/packages/react/src/components/divider.test.tsx +++ b/packages/react/src/components/divider.test.tsx @@ -16,10 +16,9 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; -import "@testing-library/jest-dom"; import { Divider } from "./divider"; -describe("Divider Component", () => { +describe("", () => { it("renders a divider with no text", () => { render(); const divider = screen.getByTestId("divider-no-text"); diff --git a/packages/react/src/components/field-info.test.tsx b/packages/react/src/components/field-info.test.tsx deleted file mode 100644 index 5cb72a01..00000000 --- a/packages/react/src/components/field-info.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { FieldInfo } from "./field-info"; -import { FieldApi } from "@tanstack/react-form"; - -describe("FieldInfo Component", () => { - // Create a mock FieldApi with errors - const createMockFieldWithErrors = (errors: string[]) => { - return { - state: { - meta: { - isTouched: true, - errors, - }, - }, - } as unknown as FieldApi; - }; - - // Create a mock FieldApi without errors - const createMockFieldWithoutErrors = () => { - return { - state: { - meta: { - isTouched: true, - errors: [], - }, - }, - } as unknown as FieldApi; - }; - - // Create a mock FieldApi that's not touched - const createMockFieldNotTouched = () => { - return { - state: { - meta: { - isTouched: false, - errors: ["This field is required"], - }, - }, - } as unknown as FieldApi; - }; - - it("renders error message when field is touched and has errors", () => { - const errorMessage = "This field is required"; - const field = createMockFieldWithErrors([errorMessage]); - - render(); - - const errorElement = screen.getByRole("alert"); - expect(errorElement).toBeInTheDocument(); - expect(errorElement).toHaveClass("fui-form__error"); - expect(errorElement).toHaveTextContent(errorMessage); - }); - - it("renders nothing when field is touched but has no errors", () => { - const field = createMockFieldWithoutErrors(); - - const { container } = render(); - - // The component should render nothing - expect(container).toBeEmptyDOMElement(); - }); - - it("renders nothing when field is not touched, even with errors", () => { - const field = createMockFieldNotTouched(); - - const { container } = render(); - - // The component should render nothing - expect(container).toBeEmptyDOMElement(); - }); - - it("applies custom className to the error message", () => { - const errorMessage = "This field is required"; - const field = createMockFieldWithErrors([errorMessage]); - - render(); - - const errorElement = screen.getByRole("alert"); - expect(errorElement).toHaveClass("fui-form__error"); - expect(errorElement).toHaveClass("custom-error"); - }); - - it("accepts and passes through additional props", () => { - const errorMessage = "This field is required"; - const field = createMockFieldWithErrors([errorMessage]); - - render(); - - const errorElement = screen.getByTestId("error-message"); - expect(errorElement).toHaveAttribute("aria-labelledby", "form-field"); - }); - - it("displays only the first error when multiple errors exist", () => { - const errors = ["First error", "Second error"]; - const field = createMockFieldWithErrors(errors); - - render(); - - const errorElement = screen.getByRole("alert"); - expect(errorElement).toHaveTextContent(errors[0]); - expect(errorElement).not.toHaveTextContent(errors[1]); - }); -}); diff --git a/packages/react/src/components/field-info.tsx b/packages/react/src/components/field-info.tsx deleted file mode 100644 index 024b282d..00000000 --- a/packages/react/src/components/field-info.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { FieldApi } from "@tanstack/react-form"; -import { ComponentProps } from "react"; -import { cn } from "~/utils/cn"; - -export type FieldInfoProps = ComponentProps<"div"> & { - field: FieldApi; -}; - -export function FieldInfo({ field, className, ...props }: FieldInfoProps) { - return ( - <> - {field.state.meta.isTouched && field.state.meta.errors.length ? ( -
- {field.state.meta.errors[0]} -
- ) : null} - - ); -} diff --git a/packages/react/src/components/form.test.tsx b/packages/react/src/components/form.test.tsx new file mode 100644 index 00000000..53b9b380 --- /dev/null +++ b/packages/react/src/components/form.test.tsx @@ -0,0 +1,235 @@ +import { describe, it, expect, afterEach, vi, beforeEach } from "vitest"; +import { render, screen, cleanup, renderHook, act, waitFor } from "@testing-library/react"; +import { form } from "./form"; +import { ComponentProps } from "react"; + +vi.mock("~/components/button", () => { + return { + Button: (props: ComponentProps<"button">) =>