feat(frontend): add email validation and loading state to lead capture form

- Add isValidEmail function with proper regex validation (rejects weak emails like a@b)
- Add loading state with isSubmitting to prevent double-submission
- Disable submit button and show 'Submitting...' text during submission
- Add i18n translations for ENTERPRISE$FORM_SUBMITTING
- Add comprehensive tests for email validation and loading state

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
openhands
2026-03-19 18:54:08 +00:00
parent aa6623731e
commit 05cfa59a3c
6 changed files with 157 additions and 6 deletions

View File

@@ -1,7 +1,10 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { FormInput } from "#/components/features/onboarding/form-input";
import {
FormInput,
isValidEmail,
} from "#/components/features/onboarding/form-input";
describe("FormInput", () => {
const defaultProps = {
@@ -168,5 +171,45 @@ describe("FormInput", () => {
const textarea = screen.getByTestId("form-input-test-input");
expect(textarea).toHaveClass("border-red-500");
});
it("should show error border for invalid email when showError is true", () => {
render(
<FormInput {...defaultProps} type="email" value="invalid" showError />,
);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveClass("border-red-500");
});
it("should not show error border for valid email when showError is true", () => {
render(
<FormInput
{...defaultProps}
type="email"
value="test@example.com"
showError
/>,
);
const input = screen.getByTestId("form-input-test-input");
expect(input).not.toHaveClass("border-red-500");
});
});
describe("isValidEmail", () => {
it("should return true for valid emails", () => {
expect(isValidEmail("test@example.com")).toBe(true);
expect(isValidEmail("user.name@domain.org")).toBe(true);
expect(isValidEmail("user+tag@domain.co.uk")).toBe(true);
});
it("should return false for invalid emails", () => {
expect(isValidEmail("")).toBe(false);
expect(isValidEmail("a@b")).toBe(false);
expect(isValidEmail("invalid")).toBe(false);
expect(isValidEmail("@domain.com")).toBe(false);
expect(isValidEmail("user@")).toBe(false);
expect(isValidEmail("user@.com")).toBe(false);
});
});
});

View File

@@ -299,5 +299,82 @@ describe("InformationRequestForm", () => {
// Field with value should not be invalid
expect(nameInput).toHaveAttribute("aria-invalid", "false");
});
it("should not navigate when email is invalid", async () => {
const user = userEvent.setup();
renderWithRouter();
await user.type(screen.getByTestId("form-input-name"), "John Doe");
await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
await user.type(screen.getByTestId("form-input-email"), "invalid-email");
await user.type(screen.getByTestId("form-input-message"), "Hello world");
const submitButton = screen.getByRole("button", {
name: "ENTERPRISE$FORM_SUBMIT",
});
await user.click(submitButton);
expect(mockNavigate).not.toHaveBeenCalled();
expect(mockTrackEnterpriseLeadFormSubmitted).not.toHaveBeenCalled();
});
});
describe("loading state", () => {
it("should disable submit button after submission", async () => {
const user = userEvent.setup();
renderWithRouter();
await user.type(screen.getByTestId("form-input-name"), "John Doe");
await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
await user.type(screen.getByTestId("form-input-email"), "john@example.com");
await user.type(screen.getByTestId("form-input-message"), "Hello world");
const submitButton = screen.getByRole("button", {
name: "ENTERPRISE$FORM_SUBMIT",
});
await user.click(submitButton);
expect(submitButton).toBeDisabled();
});
it("should show submitting text after submission", async () => {
const user = userEvent.setup();
renderWithRouter();
await user.type(screen.getByTestId("form-input-name"), "John Doe");
await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
await user.type(screen.getByTestId("form-input-email"), "john@example.com");
await user.type(screen.getByTestId("form-input-message"), "Hello world");
const submitButton = screen.getByRole("button", {
name: "ENTERPRISE$FORM_SUBMIT",
});
await user.click(submitButton);
expect(screen.getByText("ENTERPRISE$FORM_SUBMITTING")).toBeInTheDocument();
});
it("should prevent double submission", async () => {
const user = userEvent.setup();
renderWithRouter();
await user.type(screen.getByTestId("form-input-name"), "John Doe");
await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
await user.type(screen.getByTestId("form-input-email"), "john@example.com");
await user.type(screen.getByTestId("form-input-message"), "Hello world");
const submitButton = screen.getByRole("button", {
name: "ENTERPRISE$FORM_SUBMIT",
});
// Click multiple times rapidly
await user.click(submitButton);
await user.click(submitButton);
await user.click(submitButton);
// Should only track once
expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,3 +1,6 @@
export const isValidEmail = (email: string): boolean =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
interface FormInputProps {
id: string;
label: string;
@@ -22,7 +25,9 @@ export function FormInput({
showError = false,
}: FormInputProps) {
const inputId = `form-input-${id}`;
const hasError = showError && required && !value.trim();
const isEmailInvalid =
type === "email" && !!value.trim() && !isValidEmail(value.trim());
const hasError = showError && ((required && !value.trim()) || isEmailInvalid);
const inputClassName = `w-full min-h-10 rounded border bg-[#050505] px-3 py-2 text-sm leading-5 text-white placeholder:text-[#8C8C8C] placeholder:leading-5 focus:outline-none transition-colors ${
hasError
? "border-red-500 focus:border-red-500"

View File

@@ -5,7 +5,7 @@ import { I18nKey } from "#/i18n/declaration";
import { useTracking } from "#/hooks/use-tracking";
import { Card } from "#/ui/card";
import { Text } from "#/ui/typography";
import { FormInput } from "./form-input";
import { FormInput, isValidEmail } from "./form-input";
import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react";
import CloudIcon from "#/icons/cloud-minimal.svg?react";
import StackedIcon from "#/icons/stacked.svg?react";
@@ -31,22 +31,27 @@ export function InformationRequestForm({
message: "",
});
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
setHasAttemptedSubmit(true);
// Check if all required fields are filled
// Check if all required fields are filled and email is valid
const isValid =
formData.name.trim() &&
formData.company.trim() &&
formData.email.trim() &&
isValidEmail(formData.email.trim()) &&
formData.message.trim();
if (!isValid) {
return;
}
setIsSubmitting(true);
// TODO: Implement actual form submission API call
// Track form submission in PostHog
trackEnterpriseLeadFormSubmitted({
@@ -170,10 +175,13 @@ export function InformationRequestForm({
</button>
<button
type="submit"
disabled={isSubmitting}
aria-label={t(I18nKey.ENTERPRISE$FORM_SUBMIT)}
className="flex-1 px-6 py-2.5 text-sm rounded bg-white text-black border border-white hover:bg-gray-100 transition-colors"
className="flex-1 px-6 py-2.5 text-sm rounded bg-white text-black border border-white hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{t(I18nKey.ENTERPRISE$FORM_SUBMIT)}
{isSubmitting
? t(I18nKey.ENTERPRISE$FORM_SUBMITTING)
: t(I18nKey.ENTERPRISE$FORM_SUBMIT)}
</button>
</div>
</form>

View File

@@ -1219,6 +1219,7 @@ export enum I18nKey {
ENTERPRISE$FORM_MESSAGE_SAAS_PLACEHOLDER = "ENTERPRISE$FORM_MESSAGE_SAAS_PLACEHOLDER",
ENTERPRISE$FORM_MESSAGE_SELF_HOSTED_PLACEHOLDER = "ENTERPRISE$FORM_MESSAGE_SELF_HOSTED_PLACEHOLDER",
ENTERPRISE$FORM_SUBMIT = "ENTERPRISE$FORM_SUBMIT",
ENTERPRISE$FORM_SUBMITTING = "ENTERPRISE$FORM_SUBMITTING",
ENTERPRISE$REQUEST_SUBMITTED_TITLE = "ENTERPRISE$REQUEST_SUBMITTED_TITLE",
ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION = "ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION",
ENTERPRISE$DONE_BUTTON = "ENTERPRISE$DONE_BUTTON",

View File

@@ -20728,6 +20728,23 @@
"tr": "Gönder",
"uk": "Надіслати"
},
"ENTERPRISE$FORM_SUBMITTING": {
"en": "Submitting...",
"ja": "送信中...",
"zh-CN": "提交中...",
"zh-TW": "提交中...",
"ko-KR": "제출 중...",
"no": "Sender inn...",
"ar": "جارٍ الإرسال...",
"de": "Wird gesendet...",
"fr": "Envoi en cours...",
"it": "Invio in corso...",
"pt": "Enviando...",
"es": "Enviando...",
"ca": "Enviant...",
"tr": "Gönderiliyor...",
"uk": "Надсилається..."
},
"ENTERPRISE$REQUEST_SUBMITTED_TITLE": {
"en": "Request submitted",
"ja": "リクエストが送信されました",