From 05cfa59a3c417411c79b70a7a44a4eda45474eaa Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 19 Mar 2026 18:54:08 +0000 Subject: [PATCH] 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 --- .../features/onboarding/form-input.test.tsx | 45 ++++++++++- .../information-request-form.test.tsx | 77 +++++++++++++++++++ .../features/onboarding/form-input.tsx | 7 +- .../onboarding/information-request-form.tsx | 16 +++- frontend/src/i18n/declaration.ts | 1 + frontend/src/i18n/translation.json | 17 ++++ 6 files changed, 157 insertions(+), 6 deletions(-) diff --git a/frontend/__tests__/components/features/onboarding/form-input.test.tsx b/frontend/__tests__/components/features/onboarding/form-input.test.tsx index 8b6ce57fb4..19152852dd 100644 --- a/frontend/__tests__/components/features/onboarding/form-input.test.tsx +++ b/frontend/__tests__/components/features/onboarding/form-input.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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); + }); }); }); diff --git a/frontend/__tests__/components/features/onboarding/information-request-form.test.tsx b/frontend/__tests__/components/features/onboarding/information-request-form.test.tsx index 03fb61e2e5..ac617d84ba 100644 --- a/frontend/__tests__/components/features/onboarding/information-request-form.test.tsx +++ b/frontend/__tests__/components/features/onboarding/information-request-form.test.tsx @@ -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); + }); }); }); diff --git a/frontend/src/components/features/onboarding/form-input.tsx b/frontend/src/components/features/onboarding/form-input.tsx index f9d186b067..5bc4d6b5f9 100644 --- a/frontend/src/components/features/onboarding/form-input.tsx +++ b/frontend/src/components/features/onboarding/form-input.tsx @@ -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" diff --git a/frontend/src/components/features/onboarding/information-request-form.tsx b/frontend/src/components/features/onboarding/information-request-form.tsx index f30c7df809..95da7dec3c 100644 --- a/frontend/src/components/features/onboarding/information-request-form.tsx +++ b/frontend/src/components/features/onboarding/information-request-form.tsx @@ -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({ diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index d01e869c88..7717fb8618 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index f2fdd72856..2cf0dab31d 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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": "リクエストが送信されました",