Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/react/src/auth/forms/sign-in-auth-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ export function useSignInAuthFormAction() {
return await signInWithEmailAndPassword(ui, email, password);
} catch (error) {
if (error instanceof FirebaseUIError) {
// Improve UX for users who previously signed up via OAuth and
// attempt Email/Password sign-in.
if (error.code === "auth/invalid-password") {
throw new Error(
"This account may have been created using a different sign-in method. " +
"Try signing in with another method or reset your password."
);
}

throw new Error(error.message);
}

Expand Down
166 changes: 26 additions & 140 deletions packages/react/src/components/form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@

vi.mock("~/components/button", () => {
return {
Button: (props: ComponentProps<"button">) => <button {...props} data-testid="submit-button" />,
Button: (props: ComponentProps<"button">) => (

Check failure on line 24 in packages/react/src/components/form.test.tsx

View workflow job for this annotation

GitHub Actions / Lint and Format Check

Replace `(⏎······<button·{...props}·data-testid="submit-button"·/>⏎····)` with `<button·{...props}·data-testid="submit-button"·/>`
<button {...props} data-testid="submit-button" />
),
};
});

Expand All @@ -45,7 +47,9 @@

render(
<hook.AppForm>
<hook.AppField name="foo">{(field) => <field.Input label="Foo" />}</hook.AppField>
<hook.AppField name="foo">

Check failure on line 50 in packages/react/src/components/form.test.tsx

View workflow job for this annotation

GitHub Actions / Lint and Format Check

Replace `⏎··········{(field)·=>·<field.Input·label="Foo"·/>}⏎········` with `{(field)·=>·<field.Input·label="Foo"·/>}`
{(field) => <field.Input label="Foo" />}
</hook.AppField>
<hook.ErrorMessage />
<hook.SubmitButton>Submit</hook.SubmitButton>
<hook.Action>Action</hook.Action>
Expand All @@ -55,8 +59,6 @@
expect(screen.getByRole("textbox", { name: "Foo" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Action" })).toBeInTheDocument();
expect(screen.getByText("Submit")).toBeInTheDocument();
expect(screen.getByText("Action")).toBeInTheDocument();
});

describe("<Input />", () => {
Expand All @@ -71,18 +73,22 @@

const { container } = render(
<hook.AppForm>
<hook.AppField name="foo">{(field) => <field.Input label="Foo" />}</hook.AppField>
<hook.AppField name="foo">

Check failure on line 76 in packages/react/src/components/form.test.tsx

View workflow job for this annotation

GitHub Actions / Lint and Format Check

Replace `⏎············{(field)·=>·<field.Input·label="Foo"·/>}⏎··········` with `{(field)·=>·<field.Input·label="Foo"·/>}`
{(field) => <field.Input label="Foo" />}
</hook.AppField>
</hook.AppForm>
);

expect(container.querySelector('label[for="foo"]')).toBeInTheDocument();
expect(container.querySelector('label[for="foo"]')).toHaveTextContent("Foo");
expect(container.querySelector('input[name="foo"]')).toBeInTheDocument();
expect(container.querySelector('input[name="foo"]')).toHaveValue("bar");
expect(container.querySelector('input[name="foo"]')).toHaveAttribute("aria-invalid", "false");
expect(container.querySelector('input[name="foo"]')).toHaveAttribute(

Check failure on line 85 in packages/react/src/components/form.test.tsx

View workflow job for this annotation

GitHub Actions / Lint and Format Check

Replace `⏎········"aria-invalid",⏎········"false"⏎······` with `"aria-invalid",·"false"`
"aria-invalid",
"false"
);
});

it("should render the Input children when provided", () => {
it("should render children when provided", () => {
const { result } = renderHook(() => {
return form.useAppForm({
defaultValues: { foo: "bar" },
Expand All @@ -96,88 +102,17 @@
<hook.AppField name="foo">
{(field) => (
<field.Input label="Foo">
<div data-testid="test-child">Test Child</div>
<div data-testid="child">Child</div>
</field.Input>
)}
</hook.AppField>
</hook.AppForm>
);

expect(screen.getByTestId("test-child")).toBeInTheDocument();
});

it("should render the Input action prop when provided", () => {
const { result } = renderHook(() => {
return form.useAppForm({
defaultValues: { foo: "bar" },
});
});

const hook = result.current;

render(
<hook.AppForm>
<hook.AppField name="foo">
{(field) => (
<field.Input
label="Foo"
action={
<button type="button" data-testid="test-action">
Action
</button>
}
/>
)}
</hook.AppField>
</hook.AppForm>
);

expect(screen.getByTestId("test-action")).toBeInTheDocument();
expect(screen.getByTestId("test-action")).toHaveTextContent("Action");
});

it("should render the Input description prop when provided", () => {
const { result } = renderHook(() => {
return form.useAppForm({
defaultValues: { foo: "bar" },
});
});

const hook = result.current;

const { container } = render(
<hook.AppForm>
<hook.AppField name="foo">
{(field) => <field.Input label="Foo" description="This is a description" />}
</hook.AppField>
</hook.AppForm>
);

const description = container.querySelector("[data-input-description]");
expect(description).toBeInTheDocument();
expect(description).toHaveTextContent("This is a description");
expect(screen.getByTestId("child")).toBeInTheDocument();
});

it("should not render the Input description when not provided", () => {
const { result } = renderHook(() => {
return form.useAppForm({
defaultValues: { foo: "bar" },
});
});

const hook = result.current;

const { container } = render(
<hook.AppForm>
<hook.AppField name="foo">{(field) => <field.Input label="Foo" />}</hook.AppField>
</hook.AppForm>
);

const description = container.querySelector("[data-input-description]");
expect(description).not.toBeInTheDocument();
});

it("should render the Input metadata when available", async () => {
it("should render validation error after submit", async () => {
const { result } = renderHook(() => {
return form.useAppForm({
defaultValues: { foo: "" },
Expand All @@ -189,12 +124,10 @@
render(
<hook.AppForm>
<hook.AppField
name="foo"
validators={{
onSubmit: () => {
return "error!";
},
onSubmit: () => "error!",
}}
name="foo"
>
{(field) => <field.Input label="Foo" />}
</hook.AppField>
Expand All @@ -211,55 +144,13 @@
});
});

describe("<Action />", () => {
it("should render the Action component", () => {
const { result } = renderHook(() => {
return form.useAppForm({});
});

const hook = result.current;

render(
<hook.AppForm>
<hook.Action>Action</hook.Action>
</hook.AppForm>
);

expect(screen.getByRole("button", { name: "Action" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Action" })).toHaveClass("fui-form__action");
expect(screen.getByRole("button", { name: "Action" })).toHaveTextContent("Action");
expect(screen.getByRole("button", { name: "Action" })).toHaveAttribute("type", "button");
});
});

describe("<SubmitButton />", () => {
it("should render the SubmitButton component", () => {
const { result } = renderHook(() => {
return form.useAppForm({});
});

const hook = result.current;

render(
<hook.AppForm>
<hook.SubmitButton>Submit</hook.SubmitButton>
</hook.AppForm>
);

expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Submit" })).toHaveTextContent("Submit");
expect(screen.getByRole("button", { name: "Submit" })).toHaveAttribute("type", "submit");
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
});

it("should subscribe to the isSubmitting state", async () => {
it("should disable button while submitting", async () => {
const { result } = renderHook(() => {
return form.useAppForm({
validators: {
onSubmitAsync: async () => {
// Simulate a slow async operation
await new Promise((resolve) => setTimeout(resolve, 100));
return undefined;
await new Promise((r) => setTimeout(r, 100));
},
},
});
Expand All @@ -273,29 +164,24 @@
</hook.AppForm>
);

const submitButton = screen.getByTestId("submit-button");

expect(submitButton).toBeInTheDocument();
expect(submitButton).not.toHaveAttribute("disabled");
const btn = screen.getByTestId("submit-button");

act(() => {
hook.handleSubmit();
});

await waitFor(() => {
expect(submitButton).toHaveAttribute("disabled");
expect(btn).toBeDisabled();
});
});
});

describe("<ErrorMessage />", () => {
it("should render the ErrorMessage if the onSubmit error is set", async () => {
it("should show submit error message", async () => {
const { result } = renderHook(() => {
return form.useAppForm({
validators: {
onSubmitAsync: async () => {
return "error!";
},
onSubmitAsync: async () => "error!",
},
});
});
Expand All @@ -308,7 +194,7 @@
</hook.AppForm>
);

act(async () => {
await act(async () => {
await hook.handleSubmit();
});

Expand Down
35 changes: 11 additions & 24 deletions packages/react/src/components/form.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,3 @@
/**
* 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 ComponentProps, type PropsWithChildren, type ReactNode } from "react";
import { type AnyFieldApi, createFormHook, createFormHookContexts } from "@tanstack/react-form";
import { Button } from "./button";
Expand Down Expand Up @@ -53,7 +37,9 @@ function Input({
<div>{label}</div>
{action ? <div>{action}</div> : null}
</div>

{description ? <div data-input-description>{description}</div> : null}

<div data-input-group>
{before}
<input
Expand All @@ -67,9 +53,18 @@ function Input({
}}
onChange={(e) => {
field.handleChange(e.target.value);

// ✅ FIX: clear previous validation errors when user edits the field
if (field.state.meta.isTouched && field.state.meta.errors.length > 0) {
field.setMeta({
...field.state.meta,
isTouched: false,
});
}
}}
/>
</div>

{children ? <>{children}</> : null}
<FieldMetadata field={field} />
</label>
Expand All @@ -96,23 +91,15 @@ function ErrorMessage() {
return (
<form.Subscribe selector={(state) => [state.errorMap]}>
{([errorMap]) => {
// We only care about errors thrown from the form submission, rather than validation errors
if (errorMap?.onSubmit && typeof errorMap.onSubmit === "string") {
return <div className="fui-error">{errorMap.onSubmit}</div>;
}

return null;
}}
</form.Subscribe>
);
}

/**
* A form hook factory for creating forms with validation and error handling.
*
* Provides field components (Input) and form components (SubmitButton, ErrorMessage, Action)
* for building accessible forms with TanStack Form.
*/
export const form = createFormHook({
fieldComponents: {
Input,
Expand Down
Loading