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 f3ae1440..95e145aa 100644 --- a/packages/react/src/auth/forms/email-link-auth-form.tsx +++ b/packages/react/src/auth/forms/email-link-auth-form.tsx @@ -113,7 +113,9 @@ export function EmailLinkAuthForm({ onEmailSent, onSignIn }: EmailLinkAuthFormPr >
- {(field) => } + + {(field) => } +
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 f3a84e45..67e30623 100644 --- a/packages/react/src/auth/forms/forgot-password-auth-form.tsx +++ b/packages/react/src/auth/forms/forgot-password-auth-form.tsx @@ -93,7 +93,9 @@ export function ForgotPasswordAuthForm({ onBackToSignInClick, onPasswordSent }: >
- {(field) => } + + {(field) => } +
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 f9db3a51..b592e88f 100644 --- a/packages/react/src/auth/forms/sign-in-auth-form.tsx +++ b/packages/react/src/auth/forms/sign-in-auth-form.tsx @@ -88,12 +88,14 @@ export function SignInAuthForm({ onSignIn, onForgotPasswordClick, onRegisterClic >
- {(field) => } + + {(field) => } +
{(field) => ( - + {onForgotPasswordClick ? ( {getTranslation(ui, "labels", "forgotPassword")} 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 26f10f2e..849023e0 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 @@ -16,7 +16,7 @@ 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 { SignUpAuthForm, useSignUpAuthForm, useSignUpAuthFormAction, useRequireDisplayName } from "./sign-up-auth-form"; import { act } from "react"; import { createUserWithEmailAndPassword } from "@firebase-ui/core"; import { createFirebaseUIProvider, createMockUI } from "~/tests/utils"; @@ -63,7 +63,8 @@ describe("useSignUpAuthFormAction", () => { expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith( expect.any(Object), "test@example.com", - "password123" + "password123", + undefined ); }); @@ -88,7 +89,8 @@ describe("useSignUpAuthFormAction", () => { expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith( expect.any(Object), "test@example.com", - "password123" + "password123", + undefined ); }); @@ -115,7 +117,35 @@ describe("useSignUpAuthFormAction", () => { }); }).rejects.toThrow("unknownError"); - expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com", "password123"); + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith( + mockUI.get(), + "test@example.com", + "password123", + undefined + ); + }); + + it("should return a callback which accepts email, password, and displayName", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + const createUserWithEmailAndPasswordMock = vi + .mocked(createUserWithEmailAndPassword) + .mockResolvedValue(mockCredential); + 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", displayName: "John Doe" }); + }); + + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith( + expect.any(Object), + "test@example.com", + "password123", + "John Doe" + ); }); }); @@ -129,8 +159,11 @@ describe("useSignUpAuthForm", () => { }); it("should allow the form to be submitted", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; const mockUI = createMockUI(); - const createUserWithEmailAndPasswordMock = vi.mocked(createUserWithEmailAndPassword); + const createUserWithEmailAndPasswordMock = vi + .mocked(createUserWithEmailAndPassword) + .mockResolvedValue(mockCredential); const { result } = renderHook(() => useSignUpAuthForm(), { wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), @@ -139,13 +172,19 @@ describe("useSignUpAuthForm", () => { act(() => { result.current.setFieldValue("email", "test@example.com"); result.current.setFieldValue("password", "password123"); + // Don't set displayName - let it be undefined (optional) }); await act(async () => { await result.current.handleSubmit(); }); - expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith(mockUI.get(), "test@example.com", "password123"); + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith( + mockUI.get(), + "test@example.com", + "password123", + undefined + ); }); it("should not allow the form to be submitted if the form is invalid", async () => { @@ -167,6 +206,32 @@ describe("useSignUpAuthForm", () => { expect(result.current.getFieldMeta("email")!.errors[0].length).toBeGreaterThan(0); expect(createUserWithEmailAndPasswordMock).not.toHaveBeenCalled(); }); + + it("should allow the form to be submitted with displayName", async () => { + const mockUI = createMockUI(); + const createUserWithEmailAndPasswordMock = vi.mocked(createUserWithEmailAndPassword); + + const { result } = renderHook(() => useSignUpAuthForm(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + act(() => { + result.current.setFieldValue("email", "test@example.com"); + result.current.setFieldValue("password", "password123"); + result.current.setFieldValue("displayName", "John Doe"); + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith( + mockUI.get(), + "test@example.com", + "password123", + "John Doe" + ); + }); }); describe("", () => { @@ -174,11 +239,17 @@ describe("", () => { vi.clearAllMocks(); }); + afterEach(() => { + cleanup(); + }); + it("should render the form correctly", () => { const mockUI = createMockUI({ locale: registerLocale("test", { labels: { createAccount: "createAccount", + emailAddress: "emailAddress", + password: "password", }, }), }); @@ -193,9 +264,9 @@ describe("", () => { 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(); + // Make sure we have an email and password input with translated labels + expect(screen.getByRole("textbox", { name: /emailAddress/ })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /password/ })).toBeInTheDocument(); // Ensure the "Create Account" button is present and is a submit button const createAccountButton = screen.getByRole("button", { name: "createAccount" }); @@ -256,4 +327,178 @@ describe("", () => { expect(screen.getByText("Please enter a valid email address")).toBeInTheDocument(); }); + + it("should render displayName field when requireDisplayName behavior is enabled", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + createAccount: "createAccount", + emailAddress: "emailAddress", + password: "password", + displayName: "displayName", + }, + }), + behaviors: [ + { + requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + }, + ], + }); + + 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 all three inputs with translated labels + expect(screen.getByRole("textbox", { name: /emailAddress/ })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /password/ })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /displayName/ })).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"); + }); + + it("should not render displayName field when requireDisplayName behavior is not enabled", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + createAccount: "createAccount", + emailAddress: "emailAddress", + password: "password", + displayName: "displayName", + }, + }), + behaviors: [], // Explicitly set empty behaviors array + }); + + const { container } = render( + + + + ); + + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + expect(screen.getByRole("textbox", { name: /email/ })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /password/ })).toBeInTheDocument(); + expect(screen.queryByRole("textbox", { name: /displayName/ })).not.toBeInTheDocument(); + }); + + it("should trigger displayName validation errors when the form is blurred and requireDisplayName is enabled", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + errors: { + displayNameRequired: "Please provide a display name", + }, + labels: { + displayName: "displayName", + }, + }), + behaviors: [ + { + requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + }, + ], + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form.fui-form"); + expect(form).toBeInTheDocument(); + + const displayNameInput = screen.getByRole("textbox", { name: /displayName/ }); + expect(displayNameInput).toBeInTheDocument(); + + act(() => { + fireEvent.blur(displayNameInput); + }); + + expect(screen.getByText("Please provide a display name")).toBeInTheDocument(); + }); + + it("should not trigger displayName validation when requireDisplayName is not enabled", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + errors: { + displayNameRequired: "Please provide a display name", + }, + labels: { + displayName: "displayName", + }, + }), + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form.fui-form"); + expect(form).toBeInTheDocument(); + + // Display name field should not be present + expect(screen.queryByRole("textbox", { name: "displayName" })).not.toBeInTheDocument(); + }); +}); + +describe("useRequireDisplayName", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should return true when requireDisplayName behavior is enabled", () => { + const mockUI = createMockUI({ + behaviors: [ + { + requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + }, + ], + }); + + const { result } = renderHook(() => useRequireDisplayName(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toBe(true); + }); + + it("should return false when requireDisplayName behavior is not enabled", () => { + const mockUI = createMockUI({ + behaviors: [], + }); + + const { result } = renderHook(() => useRequireDisplayName(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toBe(false); + }); + + it("should return false when behaviors array is empty", () => { + const mockUI = createMockUI(); + + const { result } = renderHook(() => useRequireDisplayName(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toBe(false); + }); }); 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 3fa87439..1b36ccde 100644 --- a/packages/react/src/auth/forms/sign-up-auth-form.tsx +++ b/packages/react/src/auth/forms/sign-up-auth-form.tsx @@ -16,12 +16,18 @@ "use client"; -import { FirebaseUIError, getTranslation, createUserWithEmailAndPassword } from "@firebase-ui/core"; +import { FirebaseUIError, getTranslation, createUserWithEmailAndPassword, hasBehavior } 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"; +import { z } from "zod"; + +export function useRequireDisplayName() { + const ui = useUI(); + return hasBehavior(ui, "requireDisplayName"); +} export type SignUpAuthFormProps = { onSignUp?: (credential: UserCredential) => void; @@ -32,9 +38,9 @@ export function useSignUpAuthFormAction() { const ui = useUI(); return useCallback( - async ({ email, password }: { email: string; password: string }) => { + async ({ email, password, displayName }: { email: string; password: string; displayName?: string }) => { try { - return await createUserWithEmailAndPassword(ui, email, password); + return await createUserWithEmailAndPassword(ui, email, password, displayName); } catch (error) { if (error instanceof FirebaseUIError) { throw new Error(error.message); @@ -51,12 +57,14 @@ export function useSignUpAuthFormAction() { export function useSignUpAuthForm(onSuccess?: SignUpAuthFormProps["onSignUp"]) { const schema = useSignUpAuthFormSchema(); const action = useSignUpAuthFormAction(); + const requireDisplayName = useRequireDisplayName(); return form.useAppForm({ defaultValues: { email: "", password: "", - }, + displayName: requireDisplayName ? "" : undefined, + } as z.infer, validators: { onBlur: schema, onSubmit: schema, @@ -75,6 +83,7 @@ export function useSignUpAuthForm(onSuccess?: SignUpAuthFormProps["onSignUp"]) { export function SignUpAuthForm({ onBackToSignInClick, onSignUp }: SignUpAuthFormProps) { const ui = useUI(); const form = useSignUpAuthForm(onSignUp); + const requireDisplayName = useRequireDisplayName(); return (
- {(field) => } + + {(field) => } +
- {(field) => } + + {(field) => } +
+ {requireDisplayName ? ( +
+ + {(field) => } + +
+ ) : null}
{getTranslation(ui, "labels", "createAccount")} diff --git a/packages/translations/src/locales/en-us.ts b/packages/translations/src/locales/en-us.ts index be83d424..f783739d 100644 --- a/packages/translations/src/locales/en-us.ts +++ b/packages/translations/src/locales/en-us.ts @@ -57,6 +57,7 @@ export const enUS = { labels: { emailAddress: "Email Address", password: "Password", + displayName: "Display Name", forgotPassword: "Forgot Password?", register: "Register", signIn: "Sign In", diff --git a/packages/translations/src/types.ts b/packages/translations/src/types.ts index 7d369c17..b00016ec 100644 --- a/packages/translations/src/types.ts +++ b/packages/translations/src/types.ts @@ -62,6 +62,7 @@ export type Translations = { labels?: { emailAddress?: string; password?: string; + displayName?: string; forgotPassword?: string; register?: string; signIn?: string;