From 3c1b7d062ac7c04c90671e43ce84e524b6f14a16 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 2 Oct 2025 16:39:09 +0100 Subject: [PATCH 1/4] feat(react): Support display name field when behavior is enabled --- .../src/auth/forms/sign-up-auth-form.test.tsx | 243 +++++++++++++++++- .../src/auth/forms/sign-up-auth-form.tsx | 31 ++- packages/react/tests/utils.tsx | 2 +- packages/react/tsconfig.json | 3 +- packages/translations/src/locales/en-us.ts | 1 + packages/translations/src/types.ts | 1 + 6 files changed, 261 insertions(+), 20 deletions(-) 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 6b6c3119..ee112d70 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"; @@ -60,7 +60,7 @@ describe("useSignUpAuthFormAction", () => { await result.current({ email: "test@example.com", password: "password123" }); }); - expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith(expect.any(Object), "test@example.com", "password123"); + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith(expect.any(Object), "test@example.com", "password123", undefined); }); it("should return a credential on success", async () => { @@ -79,7 +79,7 @@ describe("useSignUpAuthFormAction", () => { expect(credential).toBe(mockCredential); }); - expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith(expect.any(Object), "test@example.com", "password123"); + expect(createUserWithEmailAndPasswordMock).toHaveBeenCalledWith(expect.any(Object), "test@example.com", "password123", undefined); }); it("should throw an unknown error when its not a FirebaseUIError", async () => { @@ -105,7 +105,23 @@ 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"); }); }); @@ -119,8 +135,9 @@ 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 }), @@ -129,13 +146,14 @@ 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 () => { @@ -157,6 +175,27 @@ 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("", () => { @@ -164,11 +203,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", }, }), }); @@ -183,9 +228,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" }); @@ -246,4 +291,184 @@ 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( + + + + ); + + // There should be only one form + const form = container.querySelectorAll("form.fui-form"); + expect(form.length).toBe(1); + + // Make sure we have email and password inputs but not displayName + expect(screen.getByRole("textbox", { name: /emailAddress/ })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /password/ })).toBeInTheDocument(); + expect(screen.queryByRole("textbox", { name: /displayName/ })).not.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 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/ }); + + 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 1f496c63..35939578 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); @@ -49,14 +55,17 @@ export function useSignUpAuthFormAction() { } export function useSignUpAuthForm(onSuccess?: SignUpAuthFormProps["onSignUp"]) { + const ui = useUI(); 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 +84,7 @@ export function useSignUpAuthForm(onSuccess?: SignUpAuthFormProps["onSignUp"]) { export function SignUpAuthForm({ onBackToSignInClick, onSignUp }: SignUpAuthFormProps) { const ui = useUI(); const form = useSignUpAuthForm(onSignUp); + const requireDisplayName = useRequireDisplayName(); return (
- } /> + } />
- } /> + } />
+ {requireDisplayName ? ( +
+ } /> +
+ ) : null}
- - {getTranslation(ui, "labels", "createAccount")} - + {getTranslation(ui, "labels", "createAccount")}
{onBackToSignInClick ? ( diff --git a/packages/react/tests/utils.tsx b/packages/react/tests/utils.tsx index 12af93d0..ad2c9c56 100644 --- a/packages/react/tests/utils.tsx +++ b/packages/react/tests/utils.tsx @@ -9,7 +9,7 @@ export function createMockUI(overrides?: Partial app: {} as FirebaseApp, auth: {} as Auth, locale: enUs, - behaviors: [] as Partial>[], + behaviors: [], ...overrides, }); } diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index a9786808..4e131c61 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -7,7 +7,8 @@ "~/*": ["./src/*"], "~/tests/*": ["./tests/*"], "@firebase-ui/core": ["../core/src/index.ts"], - "@firebase-ui/styles": ["../styles/src/index.ts"] + "@firebase-ui/styles": ["../styles/src/index.ts"], + "@firebase-ui/translations": ["../translations/src/index.ts"] } }, "include": ["src", "eslint.config.js", "vite.config.ts", "setup-test.ts"] diff --git a/packages/translations/src/locales/en-us.ts b/packages/translations/src/locales/en-us.ts index 1d660e43..f8001a73 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 66d7e786..db520989 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; From 31bc80c8c66b9b14483dd1ab55aaaf0739d9d718 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Fri, 3 Oct 2025 18:12:26 +0100 Subject: [PATCH 2/4] chore: Linting fix --- packages/react/src/auth/forms/sign-up-auth-form.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 867d6831..1008ca61 100644 --- a/packages/react/src/auth/forms/sign-up-auth-form.tsx +++ b/packages/react/src/auth/forms/sign-up-auth-form.tsx @@ -104,7 +104,9 @@ export function SignUpAuthForm({ onBackToSignInClick, onSignUp }: SignUpAuthForm {requireDisplayName ? (
- } /> + + {(field) => } +
) : null} From 15d416ada3cfafd6af6dce0475edafb860634106 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Fri, 3 Oct 2025 18:12:51 +0100 Subject: [PATCH 3/4] chore: Linting fix --- packages/react/src/auth/forms/sign-up-auth-form.tsx | 1 - 1 file changed, 1 deletion(-) 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 1008ca61..493f73aa 100644 --- a/packages/react/src/auth/forms/sign-up-auth-form.tsx +++ b/packages/react/src/auth/forms/sign-up-auth-form.tsx @@ -55,7 +55,6 @@ export function useSignUpAuthFormAction() { } export function useSignUpAuthForm(onSuccess?: SignUpAuthFormProps["onSignUp"]) { - const ui = useUI(); const schema = useSignUpAuthFormSchema(); const action = useSignUpAuthFormAction(); const requireDisplayName = useRequireDisplayName(); From 513b23fc46d135651e5b5c37f8f988c8ea7e6423 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Mon, 6 Oct 2025 09:11:04 +0100 Subject: [PATCH 4/4] fix: Update test query matchers --- packages/react/src/auth/forms/email-link-auth-form.tsx | 4 +++- .../react/src/auth/forms/forgot-password-auth-form.tsx | 4 +++- packages/react/src/auth/forms/sign-in-auth-form.tsx | 6 ++++-- .../react/src/auth/forms/sign-up-auth-form.test.tsx | 10 ++-------- packages/react/src/auth/forms/sign-up-auth-form.tsx | 8 ++++++-- 5 files changed, 18 insertions(+), 14 deletions(-) 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 fb4d2da1..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 @@ -385,19 +385,12 @@ describe("", () => { ); - // There should be only one form const form = container.querySelectorAll("form.fui-form"); expect(form.length).toBe(1); - // Make sure we have email and password inputs but not displayName - expect(screen.getByRole("textbox", { name: /emailAddress/ })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /email/ })).toBeInTheDocument(); expect(screen.getByRole("textbox", { name: /password/ })).toBeInTheDocument(); expect(screen.queryByRole("textbox", { name: /displayName/ })).not.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 trigger displayName validation errors when the form is blurred and requireDisplayName is enabled", () => { @@ -427,6 +420,7 @@ describe("", () => { expect(form).toBeInTheDocument(); const displayNameInput = screen.getByRole("textbox", { name: /displayName/ }); + expect(displayNameInput).toBeInTheDocument(); act(() => { fireEvent.blur(displayNameInput); 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 493f73aa..1b36ccde 100644 --- a/packages/react/src/auth/forms/sign-up-auth-form.tsx +++ b/packages/react/src/auth/forms/sign-up-auth-form.tsx @@ -96,10 +96,14 @@ export function SignUpAuthForm({ onBackToSignInClick, onSignUp }: SignUpAuthForm >
- {(field) => } + + {(field) => } +
- {(field) => } + + {(field) => } +
{requireDisplayName ? (