mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "リクエストが送信されました",
|
||||
|
||||
Reference in New Issue
Block a user