Skip to content

Commit

Permalink
Contact page
Browse files Browse the repository at this point in the history
  • Loading branch information
gilhanan committed Oct 22, 2023
1 parent bea2a98 commit 17e4c60
Show file tree
Hide file tree
Showing 24 changed files with 925 additions and 0 deletions.
4 changes: 4 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ 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 = "[email protected]";

function MockImage({ priority, ...props }: ImageProps) {
return createElement("img", {
...props,
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@testing-library/react": "14.0.0",
"@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",
Expand All @@ -35,8 +36,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",
Expand Down
146 changes: 146 additions & 0 deletions src/app/contact/components/ContactForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { Contact } from "../models";
import { ContactForm } from "./ContactForm";

global.fetch = jest.fn(() => Promise.resolve()) as unknown as typeof fetch;

const push = jest.fn();

jest.mock("next/navigation", () => ({
useRouter: () => ({
push,
}),
}));

const getSubmitButton = () => screen.getByRole("button", { name: /submit/i });

const contact: Record<
keyof Contact,
{
name: string;
value: string;
}
> = {
name: { name: "Name", value: "John Doe" },
email: { name: "Email", value: "[email protected]" },
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 expectFormToBeEnabled = () => {
getFormComponents().forEach((field) => {
expect(field).toBeEnabled();
});
};

const expectFormToBeDisabled = () => {
getFormComponents().forEach((field) => {
expect(field).toBeDisabled();
});
};

describe("ContactForm", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("should renders form fields and submit button correctly", () => {
render(<ContactForm />);
getFormComponents().forEach((field) => {
expect(field).toBeInTheDocument();
});
});

it("should submit form data", async () => {
render(<ContactForm />);

Object.values(contact).forEach(({ name, value }) =>
fireEvent.change(screen.getByRole("textbox", { name }), {
target: { value },
}),
);

fireEvent.submit(getSubmitButton());

const body = new FormData();
Object.entries(contact).forEach(([key, { value }]) =>
body.append(key, value),
);

await waitFor(() => {
expect(fetch).toHaveBeenCalledWith("/contact/submit", {
method: "POST",
body,
});
});

expect(push).toHaveBeenCalledWith("/contact/success");
});

it("should disable form fields while submitting", async () => {
render(<ContactForm />);

expectFormToBeEnabled();

fireEvent.submit(getSubmitButton());

await waitFor(() => {
expectFormToBeDisabled();
});

await waitFor(() => {
expectFormToBeEnabled();
});
});

it("should show error message if submission fails", async () => {
(fetch as jest.Mock).mockRejectedValue(new Error());

render(<ContactForm />);

fireEvent.submit(getSubmitButton());

await waitFor(() => {
expectFormToBeDisabled();
});

await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});

expect(push).not.toHaveBeenCalled();

await waitFor(() => {
expectFormToBeEnabled();
});
});

it("should hide error message when form is changed", async () => {
(fetch as jest.Mock).mockRejectedValue(new Error());

render(<ContactForm />);

fireEvent.submit(getSubmitButton());

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();
});
});
});
65 changes: 65 additions & 0 deletions src/app/contact/components/ContactForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import { BiMailSend } from "react-icons/bi";
import { useRouter } from "next/navigation";
import { Input } from "../../forms/components/Input";
import { Submit } from "../../forms/components/Submit";
import { Field } from "../../forms/models";
import { Contact } from "../models";
import { fields } from "../utils";

export function ContactForm() {
const router = useRouter();
const [isSending, setIsSending] = useState(false);
const [isError, setIsError] = useState(false);

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
try {
e.preventDefault();
setIsSending(true);
const body = new FormData(e.currentTarget);
await fetch("/contact/submit", { method: "POST", body });
router.push("/contact/success");
} catch (error) {
setIsError(true);
} finally {
setIsSending(false);
}
}

function getField(field: Field<keyof Contact>): Field<keyof Contact> {
return {
...field,
attributes: {
...field.attributes,
disabled: isSending,
},
};
}

return (
<form
onSubmit={handleSubmit}
onChange={() => setIsError(false)}
className="flex flex-col gap-6"
>
<div className="flex flex-col gap-4">
<Input field={getField(fields.name)} />
<Input field={getField(fields.email)} />
<Input field={getField(fields.subject)} />
<Input field={getField(fields.text)} />
</div>
<div className="text-center">
<Submit
isSubmitting={isSending}
icon={<BiMailSend className="text-2xl" />}
/>
{isError && (
<p className="mt-4 text-red-500">
There was an error sending your message. Please try again later.
</p>
)}
</div>
</form>
);
}
28 changes: 28 additions & 0 deletions src/app/contact/layout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { render, screen } from "@testing-library/react";
import ContactLayout, { metadata } from "./layout";

describe("ContactLayout", () => {
it("should contain metadata", () => {
expect(metadata.title).toBeTruthy();
});

it("should render title", () => {
render(<ContactLayout> </ContactLayout>);

expect(
screen.getByRole("heading", { name: "Contact" }),
).toBeInTheDocument();
});

it("should render children", () => {
const testId = "test-children";
render(
<ContactLayout>
<div data-testid={testId}></div>
</ContactLayout>,
);

expect(screen.getByTestId(testId)).toBeInTheDocument();
expect(screen.queryAllByTestId(testId)).toHaveLength(1);
});
});
20 changes: 20 additions & 0 deletions src/app/contact/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Metadata } from "next";

export const metadata: Metadata = {
title: "Contact",
};

export default function ContactLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="mt-12 sm:mt-6">
<section className="max-w-[600px] mx-auto">
<h1 className="text-3xl text-primary text-center">Contact</h1>
<div className="mt-4">{children}</div>
</section>
</div>
);
}
6 changes: 6 additions & 0 deletions src/app/contact/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface Contact {
name: string;
email: string;
subject: string;
text: string;
}
17 changes: 17 additions & 0 deletions src/app/contact/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { render, screen } from "@testing-library/react";
import ContactPage from "./page";

const ContactForm = "ContactForm";

jest.mock("./components/ContactForm", () => ({
ContactForm: () => <div data-testid={ContactForm}></div>,
}));

describe("Contact", () => {
it("should render ContactForm", () => {
render(<ContactPage />);

expect(screen.getByTestId(ContactForm)).toBeInTheDocument();
expect(screen.queryAllByTestId(ContactForm)).toHaveLength(1);
});
});
5 changes: 5 additions & 0 deletions src/app/contact/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ContactForm } from "./components/ContactForm";

export default function ContactPage() {
return <ContactForm />;
}
38 changes: 38 additions & 0 deletions src/app/contact/submit/mailer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createTransport } from "nodemailer";
import { sendMail } from "./mailer";

jest.mock("nodemailer", () => ({
createTransport: jest.fn(() => ({
sendMail: jest.fn(),
})),
}));

describe("sendMail", () => {
it("should create transporter correctly", async () => {
expect(createTransport).toHaveBeenCalledWith({
service: "gmail",
auth: {
user: "mockUser",
pass: "mockPassword",
},
});
});

it("should send email correctly", async () => {
const mail = {
subject: "Test Subject",
html: "<h1>Hello World</h1>",
};

await sendMail(mail);

const sendMailMocked = (createTransport as jest.Mock).mock.results[0].value
.sendMail;

expect(sendMailMocked).toHaveBeenCalledWith({
...mail,
from: "Personal Portfolio <mockUser>",
to: "[email protected]",
});
});
});
24 changes: 24 additions & 0 deletions src/app/contact/submit/mailer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { SentMessageInfo, createTransport } from "nodemailer";

const { SMTP_USER: user, SMTP_PASSWORD: pass, SMTP_RECEIVER: to } = process.env;

const transporter = createTransport({
service: "gmail",
auth: {
user,
pass,
},
});

interface Mail {
subject: string;
html: string;
}

export function sendMail(mail: Mail): Promise<SentMessageInfo> {
return transporter.sendMail({
...mail,
from: `Personal Portfolio <${user}>`,
to,
});
}
Loading

0 comments on commit 17e4c60

Please sign in to comment.