mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
feat: add confirmation modal after form submission
- Create RequestSubmittedModal component matching design specs: - 448x170px, black background, border-radius 6px - Close button (X) at top right with 0.7 opacity - Title: 'Request submitted' (18px semibold) - Description text (14px, #8C8C8C) - Done button to dismiss and navigate to homepage - Update InformationRequestForm to show modal on successful submit - Navigate to '/' (homepage) when modal is closed - Add i18n translations for modal text (15 languages): - ENTERPRISE$REQUEST_SUBMITTED_TITLE - ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION - ENTERPRISE$DONE_BUTTON - MODAL$CLOSE_BUTTON_LABEL - Add unit tests for RequestSubmittedModal (10 tests) - Update form tests for modal behavior (22 tests) Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -180,17 +180,18 @@ describe("InformationRequestForm", () => {
|
||||
expect(messageInput).toHaveAttribute("aria-invalid", "false");
|
||||
});
|
||||
|
||||
it("should not navigate when form is submitted with empty fields", async () => {
|
||||
it("should not show modal when form is submitted with empty fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(screen.queryByTestId("request-submitted-modal")).not.toBeInTheDocument();
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should navigate when form is submitted with all fields filled", async () => {
|
||||
it("should show modal when form is submitted with all fields filled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
@@ -202,6 +203,26 @@ describe("InformationRequestForm", () => {
|
||||
const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(screen.getByTestId("request-submitted-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should navigate to homepage when modal Done button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter();
|
||||
|
||||
// Fill form and submit
|
||||
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);
|
||||
|
||||
// Click Done on modal
|
||||
const doneButton = screen.getByRole("button", { name: "ENTERPRISE$DONE_BUTTON" });
|
||||
await user.click(doneButton);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RequestSubmittedModal } from "#/components/features/onboarding/request-submitted-modal";
|
||||
|
||||
describe("RequestSubmittedModal", () => {
|
||||
const defaultProps = {
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
|
||||
it("should render the modal", () => {
|
||||
render(<RequestSubmittedModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("request-submitted-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the title", () => {
|
||||
render(<RequestSubmittedModal {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$REQUEST_SUBMITTED_TITLE"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the description", () => {
|
||||
render(<RequestSubmittedModal {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the Done button", () => {
|
||||
render(<RequestSubmittedModal {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "ENTERPRISE$DONE_BUTTON" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the close button", () => {
|
||||
render(<RequestSubmittedModal {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "MODAL$CLOSE_BUTTON_LABEL" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClose when Done button is clicked", async () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<RequestSubmittedModal onClose={mockOnClose} />);
|
||||
|
||||
const doneButton = screen.getByRole("button", {
|
||||
name: "ENTERPRISE$DONE_BUTTON",
|
||||
});
|
||||
await user.click(doneButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onClose when close button is clicked", async () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<RequestSubmittedModal onClose={mockOnClose} />);
|
||||
|
||||
const closeButton = screen.getByRole("button", {
|
||||
name: "MODAL$CLOSE_BUTTON_LABEL",
|
||||
});
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onClose when Escape key is pressed", async () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<RequestSubmittedModal onClose={mockOnClose} />);
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onClose when backdrop is clicked", async () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<RequestSubmittedModal onClose={mockOnClose} />);
|
||||
|
||||
// Click on the backdrop (the semi-transparent overlay)
|
||||
const backdrop = screen.getByRole("dialog").querySelector(".bg-black");
|
||||
if (backdrop) {
|
||||
await user.click(backdrop);
|
||||
}
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should have proper accessibility attributes", () => {
|
||||
render(<RequestSubmittedModal {...defaultProps} />);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||
expect(dialog).toHaveAttribute(
|
||||
"aria-label",
|
||||
"ENTERPRISE$REQUEST_SUBMITTED_TITLE",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { Card } from "#/ui/card";
|
||||
import { Text } from "#/ui/typography";
|
||||
import { FormInput } from "./form-input";
|
||||
import { RequestSubmittedModal } from "./request-submitted-modal";
|
||||
import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react";
|
||||
import CloudIcon from "#/icons/cloud.svg?react";
|
||||
import StackedIcon from "#/icons/stacked.svg?react";
|
||||
@@ -29,6 +30,7 @@ export function InformationRequestForm({
|
||||
message: "",
|
||||
});
|
||||
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -45,7 +47,12 @@ export function InformationRequestForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Implement form submission
|
||||
// TODO: Implement actual form submission API call
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setShowModal(false);
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
@@ -181,6 +188,8 @@ export function InformationRequestForm({
|
||||
<Text className="text-[#8C8C8C]">{cardDescription}</Text>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{showModal && <RequestSubmittedModal onClose={handleModalClose} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
|
||||
interface RequestSubmittedModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RequestSubmittedModal({ onClose }: RequestSubmittedModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBackdrop
|
||||
onClose={onClose}
|
||||
aria-label={t(I18nKey.ENTERPRISE$REQUEST_SUBMITTED_TITLE)}
|
||||
>
|
||||
<div
|
||||
data-testid="request-submitted-modal"
|
||||
className="w-[448px] bg-black rounded-md border border-[#242424] border-t-[#242424]"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 4px 6px -4px rgba(0, 0, 0, 0.1), 0px 10px 15px -3px rgba(0, 0, 0, 0.1)",
|
||||
}}
|
||||
>
|
||||
{/* Header with close button */}
|
||||
<div className="relative p-6 pb-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t(I18nKey.MODAL$CLOSE_BUTTON_LABEL)}
|
||||
className="absolute top-[17px] right-[17px] w-4 h-4 flex items-center justify-center opacity-70 hover:opacity-100 transition-opacity rounded-sm"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 4L4 12M4 4L12 12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Title and description */}
|
||||
<div className="flex flex-col gap-1.5 pr-8">
|
||||
<h2 className="text-lg font-semibold text-white leading-[18px] tracking-[-0.45px]">
|
||||
{t(I18nKey.ENTERPRISE$REQUEST_SUBMITTED_TITLE)}
|
||||
</h2>
|
||||
<p className="text-sm text-[#8C8C8C] leading-5">
|
||||
{t(I18nKey.ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer with Done button */}
|
||||
<div className="p-6 pt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t(I18nKey.ENTERPRISE$DONE_BUTTON)}
|
||||
className="px-4 py-2 text-sm font-medium bg-white text-black rounded hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{t(I18nKey.ENTERPRISE$DONE_BUTTON)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -1218,5 +1218,9 @@ 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$REQUEST_SUBMITTED_TITLE = "ENTERPRISE$REQUEST_SUBMITTED_TITLE",
|
||||
ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION = "ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION",
|
||||
ENTERPRISE$DONE_BUTTON = "ENTERPRISE$DONE_BUTTON",
|
||||
COMMON$BACK = "COMMON$BACK",
|
||||
MODAL$CLOSE_BUTTON_LABEL = "MODAL$CLOSE_BUTTON_LABEL",
|
||||
}
|
||||
|
||||
@@ -20711,6 +20711,57 @@
|
||||
"tr": "Gönder",
|
||||
"uk": "Надіслати"
|
||||
},
|
||||
"ENTERPRISE$REQUEST_SUBMITTED_TITLE": {
|
||||
"en": "Request submitted",
|
||||
"ja": "リクエストが送信されました",
|
||||
"zh-CN": "请求已提交",
|
||||
"zh-TW": "請求已提交",
|
||||
"ko-KR": "요청이 제출되었습니다",
|
||||
"no": "Forespørsel sendt",
|
||||
"ar": "تم إرسال الطلب",
|
||||
"de": "Anfrage gesendet",
|
||||
"fr": "Demande soumise",
|
||||
"it": "Richiesta inviata",
|
||||
"pt": "Solicitação enviada",
|
||||
"es": "Solicitud enviada",
|
||||
"ca": "Sol·licitud enviada",
|
||||
"tr": "İstek gönderildi",
|
||||
"uk": "Запит надіслано"
|
||||
},
|
||||
"ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION": {
|
||||
"en": "Your request has been submitted. We will follow up with next steps shortly.",
|
||||
"ja": "リクエストが送信されました。次のステップについて、まもなくご連絡いたします。",
|
||||
"zh-CN": "您的请求已提交。我们将很快与您联系后续步骤。",
|
||||
"zh-TW": "您的請求已提交。我們將很快與您聯繫後續步驟。",
|
||||
"ko-KR": "요청이 제출되었습니다. 곧 다음 단계에 대해 연락드리겠습니다.",
|
||||
"no": "Forespørselen din er sendt. Vi følger opp med neste trinn snart.",
|
||||
"ar": "تم إرسال طلبك. سنتواصل معك قريباً بشأن الخطوات التالية.",
|
||||
"de": "Ihre Anfrage wurde gesendet. Wir werden uns in Kürze mit den nächsten Schritten bei Ihnen melden.",
|
||||
"fr": "Votre demande a été soumise. Nous vous contacterons prochainement pour les prochaines étapes.",
|
||||
"it": "La tua richiesta è stata inviata. Ti contatteremo a breve con i prossimi passi.",
|
||||
"pt": "Sua solicitação foi enviada. Entraremos em contato em breve com os próximos passos.",
|
||||
"es": "Su solicitud ha sido enviada. Nos pondremos en contacto pronto con los próximos pasos.",
|
||||
"ca": "La seva sol·licitud s'ha enviat. Ens posarem en contacte aviat amb els propers passos.",
|
||||
"tr": "İsteğiniz gönderildi. En kısa sürede sonraki adımlar hakkında sizinle iletişime geçeceğiz.",
|
||||
"uk": "Ваш запит надіслано. Ми зв'яжемося з вами найближчим часом щодо наступних кроків."
|
||||
},
|
||||
"ENTERPRISE$DONE_BUTTON": {
|
||||
"en": "Done",
|
||||
"ja": "完了",
|
||||
"zh-CN": "完成",
|
||||
"zh-TW": "完成",
|
||||
"ko-KR": "완료",
|
||||
"no": "Ferdig",
|
||||
"ar": "تم",
|
||||
"de": "Fertig",
|
||||
"fr": "Terminé",
|
||||
"it": "Fatto",
|
||||
"pt": "Concluído",
|
||||
"es": "Hecho",
|
||||
"ca": "Fet",
|
||||
"tr": "Tamam",
|
||||
"uk": "Готово"
|
||||
},
|
||||
"COMMON$BACK": {
|
||||
"en": "Back",
|
||||
"ja": "戻る",
|
||||
@@ -20727,5 +20778,22 @@
|
||||
"ca": "Enrere",
|
||||
"tr": "Geri",
|
||||
"uk": "Назад"
|
||||
},
|
||||
"MODAL$CLOSE_BUTTON_LABEL": {
|
||||
"en": "Close",
|
||||
"ja": "閉じる",
|
||||
"zh-CN": "关闭",
|
||||
"zh-TW": "關閉",
|
||||
"ko-KR": "닫기",
|
||||
"no": "Lukk",
|
||||
"ar": "إغلاق",
|
||||
"de": "Schließen",
|
||||
"fr": "Fermer",
|
||||
"it": "Chiudi",
|
||||
"pt": "Fechar",
|
||||
"es": "Cerrar",
|
||||
"ca": "Tancar",
|
||||
"tr": "Kapat",
|
||||
"uk": "Закрити"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user