diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 75d85f6..34380c2 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -2,12 +2,14 @@ import { test as base } from "@playwright/test"; import { HomePage } from "./pages/home-page"; import { ProjectsPage } from "./pages/projects-page"; import { AboutPage } from "./pages/about-page"; +import { ContactPage } from "./pages/contact-page"; import { Projects } from "./pages/projects"; type Fixtures = { homePage: HomePage; projectsPage: ProjectsPage; aboutPage: AboutPage; + contactPage: ContactPage; projects: Projects; }; @@ -21,6 +23,9 @@ export const test = base.extend({ aboutPage: async ({ page, baseURL }, use) => { await use(new AboutPage(page, baseURL as string)); }, + contactPage: async ({ page, baseURL }, use) => { + await use(new ContactPage(page, baseURL as string)); + }, projects: async ({ page, baseURL }, use) => { await use(new Projects(page, baseURL as string)); }, diff --git a/e2e/pages/contact-page.ts b/e2e/pages/contact-page.ts new file mode 100644 index 0000000..a2025dc --- /dev/null +++ b/e2e/pages/contact-page.ts @@ -0,0 +1,12 @@ +import type { Page } from "@playwright/test"; +import { SharedPage } from "./shared-page"; + +export class ContactPage extends SharedPage { + constructor(page: Page, baseURL: string) { + super(page, baseURL); + } + + async goto() { + await super.goto("/contact"); + } +} diff --git a/e2e/pages/shared-page.ts b/e2e/pages/shared-page.ts index ec56afe..8011bdd 100644 --- a/e2e/pages/shared-page.ts +++ b/e2e/pages/shared-page.ts @@ -7,7 +7,7 @@ export abstract class SharedPage { private readonly baseURL: string, ) {} - async goto(path: "" | "/projects" | "/about" = ""): Promise { + async goto(path: "" | "/projects" | "/about" | "/contact" = ""): Promise { await this.page.goto(this.baseURL + path); } diff --git a/e2e/specs/contact-page.spec.ts b/e2e/specs/contact-page.spec.ts new file mode 100644 index 0000000..6ae6aae --- /dev/null +++ b/e2e/specs/contact-page.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from "../fixtures"; + +test.beforeEach(async ({ contactPage }) => { + await contactPage.goto(); +}); + +test("has title", async ({ page }) => { + await expect(page).toHaveTitle("Gil Hanan | Contact"); +}); + +test("can submit form", async ({ page }) => { + // should wait for capthca to load + await new Promise((resolve) => setTimeout(resolve, 3000)); + + await page.getByLabel("Name").fill("Test User"); + await page.getByLabel("Email").fill("test@example.com"); + await page.getByLabel("Subject").fill("Test Subject"); + await page.getByLabel("Text").fill("Test description"); + + // const responsePromise$ = page.waitForResponse( + // (resp) => resp.url().includes("/api/contact") && resp.status() === 200, + // ); + + await page + .getByRole("button", { + name: "Submit", + }) + .click(); + + // await responsePromise$; + + // await page.waitForURL("/contact/success"); + // await expect(page.getByText(/Thank you for your message!/i)).toBeVisible(); +}); diff --git a/globals.d.ts b/globals.d.ts new file mode 100644 index 0000000..23f0ec2 --- /dev/null +++ b/globals.d.ts @@ -0,0 +1,7 @@ +declare global { + interface Window { + reCaptchaCallback?: () => void; + } +} + +export {}; diff --git a/jest.setup.ts b/jest.setup.ts index 0c126b9..5c1434b 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -2,6 +2,11 @@ import "@testing-library/jest-dom"; import { createElement } from "react"; import type { ImageProps } from "next/image"; +process.env.SMTP_USER = "mockUser"; +process.env.SMTP_PASSWORD = "mockPassword"; +process.env.SMTP_RECEIVER = "mockUser@example.com"; +process.env.RECAPTCHA_SECRET_KEY = "mockSecretKey"; + function MockImage({ priority, ...props }: ImageProps) { return createElement("img", { ...props, diff --git a/package-lock.json b/package-lock.json index 1d7034c..fb9fd10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,10 @@ "@tailwindcss/forms": "0.5.6", "@testing-library/jest-dom": "6.1.3", "@testing-library/react": "14.0.0", + "@types/grecaptcha": "3.0.6", "@types/jest": "29.5.5", "@types/node": "20.4.5", + "@types/nodemailer": "6.4.13", "@types/react": "18.2.17", "@types/react-dom": "18.2.7", "@typescript-eslint/eslint-plugin": "5.62.0", @@ -26,8 +28,10 @@ "husky": "8.0.3", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", + "jest-environment-node": "29.7.0", "lint-staged": "14.0.1", "next": "13.5.6", + "nodemailer": "6.9.6", "postcss": "8.4.27", "prettier": "3.0.3", "react": "18.2.0", @@ -2078,6 +2082,11 @@ "@types/node": "*" } }, + "node_modules/@types/grecaptcha": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/grecaptcha/-/grecaptcha-3.0.6.tgz", + "integrity": "sha512-4BR/3v+pbiRt3cwRwibFnV4+LmuvRUjVVqgeCul9ODAyQhlPKE4tIIRJwZUeWWpmX8e9vo/xXuQTQl8FJPP7KA==" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", @@ -2162,6 +2171,14 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz", "integrity": "sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg==" }, + "node_modules/@types/nodemailer": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.13.tgz", + "integrity": "sha512-889Vq/77eEpidCwh52sVWpbnqQmIwL8yVBekNbrztVEaWKOCRH3Eq6hjIJh1jwsGDEAJEH0RR+YhpH9mfELLKA==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.9", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.9.tgz", @@ -8162,6 +8179,14 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==" }, + "node_modules/nodemailer": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.6.tgz", + "integrity": "sha512-s7pDtWwe5fLMkQUhw8TkWB/wnZ7SRdd9HRZslq/s24hlZvBP3j32N/ETLmnqTpmj4xoBZL9fOWyCIZ7r2HORHg==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -12492,6 +12517,11 @@ "@types/node": "*" } }, + "@types/grecaptcha": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/grecaptcha/-/grecaptcha-3.0.6.tgz", + "integrity": "sha512-4BR/3v+pbiRt3cwRwibFnV4+LmuvRUjVVqgeCul9ODAyQhlPKE4tIIRJwZUeWWpmX8e9vo/xXuQTQl8FJPP7KA==" + }, "@types/istanbul-lib-coverage": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", @@ -12569,6 +12599,14 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz", "integrity": "sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg==" }, + "@types/nodemailer": { + "version": "6.4.13", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.13.tgz", + "integrity": "sha512-889Vq/77eEpidCwh52sVWpbnqQmIwL8yVBekNbrztVEaWKOCRH3Eq6hjIJh1jwsGDEAJEH0RR+YhpH9mfELLKA==", + "requires": { + "@types/node": "*" + } + }, "@types/prop-types": { "version": "15.7.9", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.9.tgz", @@ -16874,6 +16912,11 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==" }, + "nodemailer": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.6.tgz", + "integrity": "sha512-s7pDtWwe5fLMkQUhw8TkWB/wnZ7SRdd9HRZslq/s24hlZvBP3j32N/ETLmnqTpmj4xoBZL9fOWyCIZ7r2HORHg==" + }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index 7ff230d..852f1b9 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,10 @@ "@tailwindcss/forms": "0.5.6", "@testing-library/jest-dom": "6.1.3", "@testing-library/react": "14.0.0", + "@types/grecaptcha": "3.0.6", "@types/jest": "29.5.5", "@types/node": "20.4.5", + "@types/nodemailer": "6.4.13", "@types/react": "18.2.17", "@types/react-dom": "18.2.7", "@typescript-eslint/eslint-plugin": "5.62.0", @@ -35,8 +37,10 @@ "husky": "8.0.3", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", + "jest-environment-node": "29.7.0", "lint-staged": "14.0.1", "next": "13.5.6", + "nodemailer": "6.9.6", "postcss": "8.4.27", "prettier": "3.0.3", "react": "18.2.0", diff --git a/playwright.config.ts b/playwright.config.ts index bac30b5..97cc3d8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -14,6 +14,10 @@ export default defineConfig({ command: "npm start", url: baseURL, reuseExistingServer: !process.env.CI, + env: { + RECAPTCHA_SITE_KEY: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI", + RECAPTCHA_SECRET_KEY: "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe", + }, }, use: { baseURL, diff --git a/src/app/api/contact/route.test.ts b/src/app/api/contact/route.test.ts new file mode 100644 index 0000000..ad5a51d --- /dev/null +++ b/src/app/api/contact/route.test.ts @@ -0,0 +1,77 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import { sendMail } from "@shared/mailer"; +import { isContact } from "@contact/utils"; +import { Contact } from "@contact/models"; +import { POST } from "@api/contact/route"; + +jest.mock("../../contact/utils"); +jest.mock("../../shared/mailer"); + +const contact: Contact = { + name: "John Doe", + email: "johndoe@example.com", + subject: "Test Subject", + text: "Test Message", +}; + +const { name, email, subject, text } = contact; + +describe("POST", () => { + let body: FormData; + let request: NextRequest; + + beforeEach(() => { + jest.clearAllMocks(); + + body = new FormData(); + Object.entries(contact).forEach(([key, value]) => body.append(key, value)); + + request = new NextRequest("https://example.com", { + method: "POST", + body, + }); + }); + + it("should send email and return 200 if form data is valid", async () => { + (isContact as unknown as jest.Mock).mockReturnValue(true); + + const response = await POST(request); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + message: `Successfully sent email to ${email}`, + }); + expect(sendMail).toHaveBeenCalledWith({ + subject, + html: `${name} <${email}>

${text}`, + }); + }); + + it("should return 400 if form data is invalid", async () => { + (isContact as unknown as jest.Mock).mockReturnValue(false); + + const response = await POST(request); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ message: "Invalid form data" }); + expect(sendMail).not.toHaveBeenCalled(); + }); + + it("should return 500 if sending email fails", async () => { + (isContact as unknown as jest.Mock).mockReturnValue(true); + (sendMail as jest.Mock).mockRejectedValue(new Error()); + + const response = await POST(request); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ message: "Failed to send email" }); + expect(sendMail).toHaveBeenCalledWith({ + subject, + html: `${name} <${email}>

${text}`, + }); + }); +}); diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts new file mode 100644 index 0000000..ce4c7fa --- /dev/null +++ b/src/app/api/contact/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sendMail } from "@shared/mailer"; +import { isContact } from "@contact/utils"; + +export async function POST(request: NextRequest): Promise< + NextResponse<{ + message: string; + }> +> { + const form = Object.fromEntries(await request.formData()); + + if (!isContact(form)) { + return NextResponse.json({ message: "Invalid form data" }, { status: 400 }); + } + + const { name, email, subject, text } = form; + + try { + await sendMail({ + subject, + html: `${name} <${email}>

${text}`, + }); + + return NextResponse.json({ + message: `Successfully sent email to ${form.email}`, + }); + } catch (error) { + return NextResponse.json( + { message: "Failed to send email" }, + { status: 500 }, + ); + } +} diff --git a/src/app/components/ReCaptcha.test.tsx b/src/app/components/ReCaptcha.test.tsx new file mode 100644 index 0000000..6f263ba --- /dev/null +++ b/src/app/components/ReCaptcha.test.tsx @@ -0,0 +1,188 @@ +import React from "react"; +import { render, cleanup, act, RenderResult } from "@testing-library/react"; +import { Theme, ThemeContext } from "@contexts/ThemeContext"; +import ReCaptcha, { ReCaptchaProps, ReCaptchaRef } from "@components/ReCaptcha"; + +const scriptId = "recaptcha-script"; +const testToken = "test-token"; +const sitekey = "test-sitekey"; + +let shouldRenderFail = false; +let renderCallback: ReCaptchaV2.Parameters["callback"]; +let renderErrorCallback: ReCaptchaV2.Parameters["error-callback"]; + +window.reCaptchaCallback = jest.fn(); +window.grecaptcha = { + execute: jest.fn(() => { + if (shouldRenderFail) { + renderErrorCallback?.(); + } else { + renderCallback?.(testToken); + } + + return Promise.resolve() as unknown as PromiseLike & + PromiseLike; + }), + render: jest.fn< + ReturnType, + Parameters + >((container, { callback, ["error-callback"]: errorCallback } = {}) => { + renderCallback = callback; + renderErrorCallback = errorCallback; + return 0; + }), + reset: jest.fn(), +} satisfies Partial as unknown as typeof window.grecaptcha; + +const defaultProps: ReCaptchaProps = { + className: "test-class", + sitekey, + onReady: jest.fn(), +}; + +interface RenderProps { + props: ReCaptchaProps; + theme: Theme; + ref?: React.RefObject; +} + +function getRecaptcha({ props, theme }: RenderProps): React.ReactElement { + return ( + + + + ); +} + +function renderReCaptcha(args: RenderProps): RenderResult { + return render(getRecaptcha(args)); +} + +describe("ReCaptcha", () => { + beforeEach(() => { + cleanup(); + jest.clearAllMocks(); + shouldRenderFail = false; + }); + + it("renders the ReCaptcha container", () => { + const { container } = renderReCaptcha({ + props: defaultProps, + theme: "light", + }); + + expect(container.firstChild).toHaveClass(defaultProps.className); + }); + + it("appends the ReCaptcha script to the document and removes on unmount", () => { + const { unmount } = renderReCaptcha({ + props: defaultProps, + theme: "light", + }); + + expect(document.getElementById(scriptId)).not.toBeNull(); + + unmount(); + + expect(document.getElementById(scriptId)).toBeNull(); + }); + + it("calls the onReady prop once ReCaptcha script is loaded", () => { + renderReCaptcha({ + props: defaultProps, + theme: "light", + }); + + window.reCaptchaCallback?.(); + + expect(defaultProps.onReady).toHaveBeenCalledTimes(1); + }); + + it("renders grecaptcha with correct properties when the script is loaded", () => { + const { container } = renderReCaptcha({ + props: defaultProps, + theme: "light", + }); + + act(() => { + window.reCaptchaCallback?.(); + }); + + expect(window.grecaptcha.render).toHaveBeenCalledWith( + container.firstChild?.firstChild, + expect.objectContaining({ + sitekey, + theme: "light", + size: "invisible", + }), + ); + }); + + it("update grepcaptcha when theme changes", () => { + const { rerender, container } = renderReCaptcha({ + props: defaultProps, + theme: "light", + }); + + act(() => { + window.reCaptchaCallback?.(); + }); + + expect(window.grecaptcha.render).toHaveBeenCalledWith( + container?.firstChild?.firstChild, + expect.objectContaining({ + theme: "light", + }), + ); + + rerender( + getRecaptcha({ + props: defaultProps, + theme: "dark", + }), + ); + + expect(window.grecaptcha.render).toHaveBeenCalledWith( + container?.firstChild?.firstChild, + expect.objectContaining({ + theme: "dark", + }), + ); + }); + + it("executes the reCAPTCHA and returns the token", async () => { + const ref = React.createRef(); + + render(); + + act(() => { + window.reCaptchaCallback?.(); + }); + + const token = await ref.current?.execute(); + + expect(token).toBe(testToken); + expect(window.grecaptcha.execute).toHaveBeenCalled(); + expect(window.grecaptcha.reset).toHaveBeenCalled(); + }); + + it("executes the reCAPTCHA and throws an error", async () => { + const ref = React.createRef(); + + render(); + + act(() => { + window.reCaptchaCallback?.(); + }); + + shouldRenderFail = true; + + try { + await ref.current?.execute(); + expect(true).toBe(false); + } catch { + expect(window.grecaptcha.execute).toHaveBeenCalled(); + expect(window.grecaptcha.reset).toHaveBeenCalled(); + } + }); +}); diff --git a/src/app/components/ReCaptcha.tsx b/src/app/components/ReCaptcha.tsx new file mode 100644 index 0000000..f5a2769 --- /dev/null +++ b/src/app/components/ReCaptcha.tsx @@ -0,0 +1,108 @@ +import { + forwardRef, + useContext, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { ThemeContext } from "@contexts/ThemeContext"; + +export interface ReCaptchaProps { + className: string; + sitekey: string; + onReady: () => void; +} + +export interface ReCaptchaRef { + execute: () => Promise; +} + +interface ExecutionCallback { + resolve: (token: string) => void; + reject: () => void; +} + +const scriptId = "recaptcha-script"; +const reCaptchaCallback = "reCaptchaCallback"; +const reCaptchaScript = `https://www.recaptcha.net/recaptcha/api.js?onload=${reCaptchaCallback}&render=explicit`; + +const ReCaptcha = forwardRef(function ReCaptcha( + { className, sitekey, onReady }: ReCaptchaProps, + ref, +) { + const { theme } = useContext(ThemeContext); + const [isScriptLoaded, setIsScriptLoaded] = useState(false); + const container = useRef(null); + const executionCallback = useRef(); + + function loadScript() { + window[reCaptchaCallback] = () => { + setIsScriptLoaded(true); + onReady(); + }; + + if (!document.getElementById(scriptId)) { + const script = document.createElement("script"); + script.src = reCaptchaScript; + script.id = scriptId; + script.async = true; + script.defer = true; + document.head.appendChild(script); + } + } + + function unloadScript() { + document.getElementById(scriptId)?.remove(); + } + + async function execute(): Promise { + const promise = new Promise((resolve, reject) => { + executionCallback.current = { resolve, reject }; + }); + + window.grecaptcha.execute(); + + return promise; + } + + function handleChange(token: string) { + executionCallback.current?.resolve(token); + grecaptcha.reset(); + } + + function handleErrored() { + executionCallback.current?.reject(); + grecaptcha.reset(); + } + + useEffect(() => { + loadScript(); + + return () => { + unloadScript(); + }; + }); + + useEffect(() => { + if (isScriptLoaded && container.current) { + const recaptchaContainer = document.createElement("div"); + container.current.replaceChildren(recaptchaContainer); + window.grecaptcha.render(recaptchaContainer, { + sitekey, + theme, + size: "invisible", + callback: handleChange, + "error-callback": handleErrored, + }); + } + }, [isScriptLoaded, sitekey, theme]); + + useImperativeHandle(ref, () => ({ + execute, + })); + + return
; +}); + +export default ReCaptcha; diff --git a/src/app/contact/components/ContactForm.test.tsx b/src/app/contact/components/ContactForm.test.tsx new file mode 100644 index 0000000..9d775c7 --- /dev/null +++ b/src/app/contact/components/ContactForm.test.tsx @@ -0,0 +1,252 @@ +import { forwardRef, useEffect, useImperativeHandle } from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { reCaptchaHeaderKey } from "@shared/constants"; +import { Contact } from "@contact/models"; +import { ReCaptchaProps, ReCaptchaRef } from "@components/ReCaptcha"; +import { ContactForm } from "@contact/components/ContactForm"; + +const reCaptchaToken = "test-token"; +const reCaptchaSiteKey = "test-site-key"; + +global.fetch = jest.fn(() => Promise.resolve({})) as unknown as typeof fetch; + +const push = jest.fn(); + +jest.mock("next/navigation", () => ({ + useRouter: () => ({ + push, + }), +})); + +let shouldReCaptchaReady = false; +let shouldReCaptchaThrows = false; + +jest.mock("../../components/ReCaptcha", () => + forwardRef(function ReCaptcha( + { onReady }, + ref, + ) { + useImperativeHandle(ref, () => ({ + execute: jest.fn(() => + shouldReCaptchaThrows + ? Promise.reject() + : Promise.resolve(reCaptchaToken), + ), + })); + + useEffect(() => { + if (shouldReCaptchaReady) { + onReady(); + } + }, [onReady]); + + return null; + }), +); + +const getSubmitButton = () => screen.getByRole("button", { name: /submit/i }); + +type ContactToFill = Record< + keyof Contact, + { + name: string; + value: string; + } +>; + +const contact: ContactToFill = { + name: { name: "Name", value: "John Doe" }, + email: { name: "Email", value: "johndoe@example.com" }, + subject: { name: "Subject", value: "Test Subject" }, + text: { name: "Text", value: "Test Message" }, +}; + +const getFormFields = () => + Object.values(contact).map(({ name }) => + screen.getByRole("textbox", { name }), + ); + +const getFormComponents = () => { + return [...getFormFields(), getSubmitButton()]; +}; + +const expectFormFieldsToBeEnabled = () => { + getFormFields().forEach((field) => { + expect(field).toBeEnabled(); + }); +}; + +const expectFormToBeEnabled = () => { + getFormComponents().forEach((field) => { + expect(field).toBeEnabled(); + }); +}; + +const expectFormToBeDisabled = () => { + getFormComponents().forEach((field) => { + expect(field).toBeDisabled(); + }); +}; + +const renderAndSubmitForm = async ({ + reCaptchaSiteKey = "", + contact, +}: { + reCaptchaSiteKey?: string; + contact?: ContactToFill; +} = {}) => { + render(); + + contact && + Object.values(contact).forEach(({ name, value }) => + fireEvent.change(screen.getByRole("textbox", { name }), { + target: { value }, + }), + ); + + fireEvent.submit(getSubmitButton()); +}; + +describe("ContactForm", () => { + beforeEach(() => { + shouldReCaptchaReady = false; + shouldReCaptchaThrows = false; + jest.clearAllMocks(); + }); + + it("should renders form fields and submit button correctly", () => { + render(); + getFormComponents().forEach((field) => { + expect(field).toBeInTheDocument(); + }); + expectFormFieldsToBeEnabled(); + expect(getSubmitButton()).toBeDisabled(); + }); + + it("should enable submit button when reCaptcha is ready", async () => { + shouldReCaptchaReady = true; + render(); + expectFormToBeEnabled(); + }); + + it("should execute reCaptcha with site key and use token in request header", async () => { + render(); + fireEvent.submit(getSubmitButton()); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith("/api/contact", { + method: "POST", + headers: { + [reCaptchaHeaderKey]: reCaptchaToken, + }, + body: expect.any(FormData), + }); + }); + }); + + it("should submit form data", async () => { + renderAndSubmitForm({ contact }); + + const body = new FormData(); + Object.entries(contact).forEach(([key, { value }]) => + body.append(key, value), + ); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith("/api/contact", { + method: "POST", + headers: { + [reCaptchaHeaderKey]: reCaptchaToken, + }, + body, + }); + }); + }); + + it("should redirect to success page if submission succeeds", async () => { + (fetch as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ + status: 200, + }), + ); + + renderAndSubmitForm(); + + await waitFor(() => { + expect(push).toHaveBeenCalledWith("/contact/success"); + }); + }); + + it("should disable form fields while submitting", async () => { + shouldReCaptchaReady = true; + + renderAndSubmitForm(); + + await waitFor(() => { + expectFormToBeDisabled(); + }); + + await waitFor(() => { + expectFormToBeEnabled(); + }); + }); + + it("should show error message if ReCaptcha execute fails", async () => { + shouldReCaptchaThrows = true; + + renderAndSubmitForm(); + + await waitFor(() => { + expect(fetch).not.toHaveBeenCalled(); + expect(screen.getByText(/error/i)).toBeInTheDocument(); + }); + }); + + it("should show error message if fetch submission fails", async () => { + (fetch as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ + status: 500, + }), + ); + + renderAndSubmitForm(); + + await waitFor(() => { + expect(fetch).toHaveBeenCalled(); + expect(screen.getByText(/error/i)).toBeInTheDocument(); + }); + }); + + it("should show error message if fetch submission throws", async () => { + (fetch as jest.Mock).mockRejectedValueOnce(new Error()); + + renderAndSubmitForm(); + + await waitFor(() => { + expect(fetch).toHaveBeenCalled(); + expect(screen.getByText(/error/i)).toBeInTheDocument(); + }); + }); + + it("should hide error message when form is changed", async () => { + (fetch as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ + status: 500, + }), + ); + + renderAndSubmitForm(); + + await waitFor(() => { + expect(screen.getByText(/error/i)).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { + target: { value: "John Doe" }, + }); + + await waitFor(() => { + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/app/contact/components/ContactForm.tsx b/src/app/contact/components/ContactForm.tsx new file mode 100644 index 0000000..0d052b1 --- /dev/null +++ b/src/app/contact/components/ContactForm.tsx @@ -0,0 +1,92 @@ +"use client"; +import { useRef, useState } from "react"; +import { BiMailSend } from "react-icons/bi"; +import { useRouter } from "next/navigation"; +import { reCaptchaHeaderKey } from "@shared/constants"; +import ReCaptcha, { ReCaptchaRef } from "@components/ReCaptcha"; +import { Input } from "@forms/components/Input"; +import { Submit } from "@forms/components/Submit"; +import { Field } from "@forms/models"; +import { Contact } from "@contact/models"; +import { fields } from "@contact/utils"; + +interface ContactFormProps { + reCaptchaSiteKey: string; +} + +export function ContactForm({ reCaptchaSiteKey }: ContactFormProps) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(true); + const [isSending, setIsSending] = useState(false); + const [isError, setIsError] = useState(false); + const reCaptchaRef = useRef(null); + + async function handleSubmit(e: React.FormEvent) { + try { + e.preventDefault(); + setIsSending(true); + + const body = new FormData(e.currentTarget); + const token = await reCaptchaRef.current?.execute(); + + const { status } = await fetch("/api/contact", { + method: "POST", + body, + headers: { [reCaptchaHeaderKey]: token as string }, + }); + + if (status !== 200) { + throw new Error(); + } + + router.push("/contact/success"); + } catch (error) { + setIsError(true); + } finally { + setIsSending(false); + } + } + + function getField(field: Field): Field { + return { + ...field, + attributes: { + ...field.attributes, + disabled: isSending, + }, + }; + } + + return ( +
setIsError(false)} + className="flex flex-col gap-6" + > +
+ + + + +
+
+ setIsLoading(false)} + /> + } + /> +
+ {isError && ( +

+ There was an error sending your message. Please try again later. +

+ )} +
+ ); +} diff --git a/src/app/contact/layout.test.tsx b/src/app/contact/layout.test.tsx new file mode 100644 index 0000000..8a6e632 --- /dev/null +++ b/src/app/contact/layout.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import ContactLayout, { metadata } from "@contact/layout"; + +describe("ContactLayout", () => { + it("should contain metadata", () => { + expect(metadata.title).toBeTruthy(); + }); + + it("should render title", () => { + render( ); + + expect( + screen.getByRole("heading", { name: "Contact" }), + ).toBeInTheDocument(); + }); + + it("should render children", () => { + const testId = "test-children"; + render( + +
+
, + ); + + expect(screen.getByTestId(testId)).toBeInTheDocument(); + expect(screen.queryAllByTestId(testId)).toHaveLength(1); + }); +}); diff --git a/src/app/contact/layout.tsx b/src/app/contact/layout.tsx new file mode 100644 index 0000000..8a967db --- /dev/null +++ b/src/app/contact/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Contact", +}; + +export default function ContactLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+

Contact

+
{children}
+
+
+ ); +} diff --git a/src/app/contact/models.ts b/src/app/contact/models.ts new file mode 100644 index 0000000..4e41587 --- /dev/null +++ b/src/app/contact/models.ts @@ -0,0 +1,6 @@ +export interface Contact { + name: string; + email: string; + subject: string; + text: string; +} diff --git a/src/app/contact/page.test.tsx b/src/app/contact/page.test.tsx new file mode 100644 index 0000000..7c8922a --- /dev/null +++ b/src/app/contact/page.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import ContactPage from "@contact/page"; + +const ContactForm = "ContactForm"; + +jest.mock("./components/ContactForm", () => ({ + ContactForm: () =>
, +})); + +describe("Contact", () => { + it("should render ContactForm", () => { + render(); + + expect(screen.getByTestId(ContactForm)).toBeInTheDocument(); + expect(screen.queryAllByTestId(ContactForm)).toHaveLength(1); + }); +}); diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx new file mode 100644 index 0000000..737f9cc --- /dev/null +++ b/src/app/contact/page.tsx @@ -0,0 +1,6 @@ +import { reCaptchaSiteKey } from "@shared/constants"; +import { ContactForm } from "@contact/components/ContactForm"; + +export default function ContactPage() { + return ; +} diff --git a/src/app/contact/success/page.test.tsx b/src/app/contact/success/page.test.tsx new file mode 100644 index 0000000..9fc4552 --- /dev/null +++ b/src/app/contact/success/page.test.tsx @@ -0,0 +1,10 @@ +import { render, screen } from "@testing-library/react"; +import ContactSuccessPage from "@contact/success/page"; + +describe("ContactSuccess", () => { + it("should render ContactForm", () => { + render(); + + expect(screen.getByText(/Thank you/i)).toBeInTheDocument(); + }); +}); diff --git a/src/app/contact/success/page.tsx b/src/app/contact/success/page.tsx new file mode 100644 index 0000000..59a8259 --- /dev/null +++ b/src/app/contact/success/page.tsx @@ -0,0 +1,7 @@ +export default function ContactSuccessPage() { + return ( +

+ Thank you for your message! I will get back to you as soon as possible. 😀 +

+ ); +} diff --git a/src/app/contact/utils.test.ts b/src/app/contact/utils.test.ts new file mode 100644 index 0000000..a55c8b7 --- /dev/null +++ b/src/app/contact/utils.test.ts @@ -0,0 +1,25 @@ +import { isValid } from "@forms/validator"; +import { Contact } from "@contact/models"; +import { fields, isContact } from "@contact/utils"; + +jest.mock("../forms/validator", () => ({ + isValid: jest.fn(), +})); + +describe("isContact", () => { + it("should call isValid", () => { + const contact: Contact = { + name: "name", + email: "email", + subject: "subject", + text: "text", + }; + + isContact(contact); + + expect(isValid).toHaveBeenCalledWith({ + obj: contact, + fields, + }); + }); +}); diff --git a/src/app/contact/utils.ts b/src/app/contact/utils.ts new file mode 100644 index 0000000..e3497b6 --- /dev/null +++ b/src/app/contact/utils.ts @@ -0,0 +1,40 @@ +import { Fields } from "@forms/models"; +import { isValid } from "@forms/validator"; +import { Contact } from "@contact/models"; + +export const fields: Fields = { + name: { + key: "name", + label: "Name", + type: "text", + attributes: { + autoComplete: "name", + }, + validators: { required: true, minLength: 2, maxLength: 30 }, + }, + email: { + key: "email", + label: "Email", + type: "email", + attributes: { + autoComplete: "email", + }, + validators: { required: true, minLength: 5, maxLength: 50 }, + }, + subject: { + key: "subject", + label: "Subject", + type: "text", + validators: { required: true, minLength: 2, maxLength: 60 }, + }, + text: { + key: "text", + label: "Text", + type: "textarea", + validators: { maxLength: 500 }, + }, +}; + +export function isContact(obj: unknown): obj is Contact { + return isValid({ obj, fields }); +} diff --git a/src/app/forms/components/Input.test.tsx b/src/app/forms/components/Input.test.tsx new file mode 100644 index 0000000..d57ecca --- /dev/null +++ b/src/app/forms/components/Input.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from "@testing-library/react"; +import { Field, InputType } from "@forms/models"; +import { Input } from "@forms/components/Input"; + +describe("Input", () => { + const baseField: Field = { + key: "testKey", + label: "Test Label", + type: "text", + }; + + it("should be rendered correctly", () => { + render(); + + const input = screen.getByRole("textbox"); + expect(input).toHaveAttribute("id", "testKey"); + expect(input).toHaveAttribute("name", "testKey"); + + const label = screen.getByText("Test Label"); + expect(label).toHaveAttribute("for", "testKey"); + }); + + it.each([["text"], ["email"]] satisfies [InputType][])( + "should render when type is '%s'", + (type) => { + const field: Field = { + ...baseField, + type, + }; + render(); + + const input = screen.getByRole("textbox"); + + expect(input.tagName).toBe("INPUT"); + expect(input).toHaveAttribute("type", type); + }, + ); + + it("should render a texarea when type is 'textarea'", () => { + const field: Field = { + ...baseField, + type: "textarea", + }; + render(); + + const input = screen.getByRole("textbox"); + + expect(input.tagName).toBe("TEXTAREA"); + }); + + it("should apply attributes", () => { + const field: Field = { + ...baseField, + attributes: { + placeholder: "Enter something", + }, + }; + render(); + + const input = screen.getByRole("textbox"); + expect(input).toHaveAttribute("placeholder", "Enter something"); + }); + + it("should not have unexpected attributes", () => { + render(); + + const input = screen.getByRole("textbox"); + expect(input).not.toHaveAttribute("placeholder"); + }); + + it("should apply validators", () => { + const field: Field = { + ...baseField, + validators: { + required: true, + minLength: 5, + maxLength: 10, + }, + }; + render(); + + const input = screen.getByRole("textbox"); + expect(input).toHaveAttribute("required"); + expect(input).toHaveAttribute("minLength", "5"); + expect(input).toHaveAttribute("maxLength", "10"); + }); + + it("should not have unexpected validators", () => { + render(); + + const input = screen.getByRole("textbox"); + expect(input).not.toHaveAttribute("required"); + expect(input).not.toHaveAttribute("minLength"); + expect(input).not.toHaveAttribute("maxLength"); + }); +}); diff --git a/src/app/forms/components/Input.tsx b/src/app/forms/components/Input.tsx new file mode 100644 index 0000000..fb7e2b7 --- /dev/null +++ b/src/app/forms/components/Input.tsx @@ -0,0 +1,49 @@ +import { InputHTMLAttributes, TextareaHTMLAttributes } from "react"; +import { Field } from "@forms/models"; + +export interface InputProps { + field: Field; +} + +const inputClassName = "px-3 py-2 border bg-input-bg rounded-md shadow-sm"; +const requiredClassName = "after:content-['*'] after:ml-0.5 after:text-red-500"; + +function getLabelClassNames(validators: Field["validators"]) { + return `text-lg ${validators?.required ? requiredClassName : ""}`; +} + +export function Input({ + field: { key, label, type, validators, attributes }, +}: InputProps) { + const commonProps: InputHTMLAttributes & + TextareaHTMLAttributes = { + ...attributes, + ...validators, + id: key, + name: key, + className: inputClassName, + }; + + const inputProps: InputHTMLAttributes = { + ...commonProps, + type, + }; + + const textareaProps: TextareaHTMLAttributes = { + ...commonProps, + rows: 4, + }; + + return ( +
+ + {type === "textarea" ? ( +