From 2c153254f2fcdbb170f6fa2b43fa553ada566f3e Mon Sep 17 00:00:00 2001 From: Dave <43282177+helloitsdave@users.noreply.github.com> Date: Wed, 8 May 2024 19:25:02 +0100 Subject: [PATCH] [registration] feat: Register form (#40) --- backend/src/routes/userRoutes.ts | 2 +- frontend/src/App.css | 105 ++++++++++-- frontend/src/App.tsx | 58 ++++++- frontend/src/api/apiService.ts | 17 ++ frontend/src/components/Login.tsx | 54 +++--- .../src/components/RegistrationForm.test.tsx | 55 +++++++ frontend/src/components/RegistrationForm.tsx | 111 +++++++++++++ .../src/components/RegistrationLink.test.tsx | 19 +++ frontend/src/components/RegistrationLink.tsx | 28 ++++ frontend/src/mocks/db.ts | 3 +- frontend/src/mocks/handlers.ts | 154 +++++++++++------- 11 files changed, 502 insertions(+), 104 deletions(-) create mode 100644 frontend/src/components/RegistrationForm.test.tsx create mode 100644 frontend/src/components/RegistrationForm.tsx create mode 100644 frontend/src/components/RegistrationLink.test.tsx create mode 100644 frontend/src/components/RegistrationLink.tsx diff --git a/backend/src/routes/userRoutes.ts b/backend/src/routes/userRoutes.ts index 737f6f76..7c83354c 100644 --- a/backend/src/routes/userRoutes.ts +++ b/backend/src/routes/userRoutes.ts @@ -20,7 +20,7 @@ router.get('/api/users', authenticateToken, async (req, res) => { } }); -router.post('/api/users', authenticateToken, async (req, res) => { +router.post('/api/users', async (req, res) => { const { email, password, username } = req.body; if (!email || !password || !username) { diff --git a/frontend/src/App.css b/frontend/src/App.css index 7aaf1c83..12ff04a1 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -22,26 +22,45 @@ body { } .action-header { - width: 40%; + width: 30%; } } +.login-container { + display: flex; + flex-direction: row; + justify-content: space-evenly; + flex-wrap: wrap; +} + .login-page { display: flex; + flex-direction: column; justify-content: center; align-items: center; - margin: 20px; - border: 2px solid white; - border-radius: 20px; +} + +.login-register { + display: flex; + flex-direction: column; + margin-bottom: 10px; + align-items: center; + text-align: center; width: 260px; - height: 280px; padding: 20px; } -.login-page form { +.login-page-form form{ display: flex; flex-direction: column; + align-items: center; + justify-content: center; gap: 20px; + border: 2px solid white; + border-radius: 20px; + width: 260px; + height: 260px; + padding: 10px; } .login-page button{ @@ -51,14 +70,15 @@ body { padding: 10px; font-size: 16px; color: white; + max-width: 200px; + min-width: 100px; } -.login-page button:hover { +.login-page button { background-color: #3f8df9; cursor: pointer; } - .notes-grid { display: flex; flex-wrap: wrap; @@ -117,7 +137,6 @@ h2 { flex-direction: row; justify-content: center; align-items: center; - margin-bottom: 20px; width: 100%; } @@ -158,7 +177,39 @@ textarea { .app-logo { width: 200px; height: 200px; - margin-bottom: 20px; +} +.registration-link-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: 0px 20px 20px 20px; + padding: 10px; + width: 300px; + border-radius: 10px; + height: 200px; + background-color: azure; + border-radius: 20px; +} + +.registration-link { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: auto; +} + +.nav-link { + text-align: center; + border: 0px solid white; + background-color: #3f8df9; + border-radius: 5px; + padding: 10px; + color: white; + text-decoration: none; + margin: 10px; + } .note-form button { @@ -189,8 +240,6 @@ textarea { cursor: pointer; } - - .edit-buttons { display: flex; justify-content: space-evenly; @@ -232,5 +281,37 @@ textarea { color: grey; font-size: 14px; } + + .registration-page { + display: flex; + flex-direction: column; + } + .registration-page-header { + text-align: center; + } + +.registration-form { + display: flex; + flex-direction: column; + gap: 20px; +} +.registration-form button { + border-radius: 5px; + background-color: #3f8df9; + border: none; + padding: 10px; + font-size: 16px; + color: white; +} + +.registration-form-header { + text-align: center; + +} + +.registration-form-error { + color: red; + text-align: center; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index db947c3e..0a4ac0fb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,20 @@ import "./App.css"; import { useState } from "react"; -import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom"; +import { + BrowserRouter as Router, + Routes, + Route, + Navigate, +} from "react-router-dom"; import NoteApp from "./NoteApp"; import Header from "./components/Header"; import Login from "./components/Login"; +import RegistrationForm from "./components/RegistrationForm"; +import RegistrationLink from "./components/RegistrationLink"; function App() { - - const [ loggedIn, setLoggedIn ] = useState(false); + const [loggedIn, setLoggedIn] = useState(false); + const [isRegistered, setRegistered] = useState(false); const handleLogin = () => { setLoggedIn(true); @@ -18,14 +25,49 @@ function App() { localStorage.removeItem("token"); }; + const handleRegistered = () => { + setRegistered(true); + }; + return ( - - : } /> - : } /> - - + + + ) : ( + + ) + } + /> + + ) : ( + + + + + ) + } + /> + + ) : ( + + ) + } + /> + + ); } diff --git a/frontend/src/api/apiService.ts b/frontend/src/api/apiService.ts index 8861dfe1..ac5809df 100644 --- a/frontend/src/api/apiService.ts +++ b/frontend/src/api/apiService.ts @@ -66,3 +66,20 @@ export const login = async (username: string, password: string) => { ); return response; }; + +export const createUser = async ( user: + { username: string, + email: string, + password: string } +) => { + const response = await api.post( + 'users', + user, + { + headers: { + "Content-Type": "application/json", + }, + } + ); + return response; +} diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index d879e30e..08b8e942 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -13,7 +13,6 @@ const Login: React.FC = ({ onLogin }) => { const [errorText, setErrorText] = useState(""); const [isDataLoading, setIsDataLoading] = useState(false); - const handleSubmit = async (e: React.FormEvent) => { setErrorText(""); setIsDataLoading(true); @@ -27,7 +26,7 @@ const Login: React.FC = ({ onLogin }) => { onLogin(); } catch (error) { const errors = error as Error | AxiosError; - // Check if the error is an AxiosError + // Check if the error is an AxiosError if (errors instanceof AxiosError) { const axiosError = errors as AxiosError; if (axiosError.response?.status === 401) { @@ -35,34 +34,37 @@ const Login: React.FC = ({ onLogin }) => { } else { setErrorText("An error occurred. Please retry"); } + } + setIsDataLoading(false); } - setIsDataLoading(false); - } -}; + }; return ( - - setUsername(e.target.value)} - data-testid="username" - required - /> - setPassword(e.target.value)} - data-testid="password" - required - /> - Login - { isDataLoading && } - {errorText !== "" && {errorText}} - + + + Existing users + setUsername(e.target.value)} + data-testid="username" + required + /> + setPassword(e.target.value)} + data-testid="password" + required + /> + Login + {isDataLoading && } + {errorText !== "" && {errorText}} + + ); }; diff --git a/frontend/src/components/RegistrationForm.test.tsx b/frontend/src/components/RegistrationForm.test.tsx new file mode 100644 index 00000000..21cecaf3 --- /dev/null +++ b/frontend/src/components/RegistrationForm.test.tsx @@ -0,0 +1,55 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import RegistrationForm, { RegistrationFormProps } from "./RegistrationForm"; +import userEvent from "@testing-library/user-event"; + +const props: RegistrationFormProps = { onRegister: () => {} }; + +describe("RegistrationForm", () => { + it("should render the Registration Form", () => { + render(); + expect(screen.getByText("Register new account")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Username")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Email")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Password")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Confirm Password")).toBeInTheDocument(); + }); + it("should show error message on failed password match", async () => { + render(); + userEvent.type(screen.getByPlaceholderText("Username"), "testerunique"); + userEvent.type(screen.getByPlaceholderText("Email"), "test@email.com"); + userEvent.type(screen.getByPlaceholderText("Password"), "pass"); + userEvent.type(screen.getByPlaceholderText("Confirm Password"), "passwor"); + userEvent.click(screen.getByText("Register")); + expect( + screen.getByText("Error: Passwords do not match") + ).toBeInTheDocument(); + }); + it("should throw error message on failed registration", async () => { + render(); + userEvent.type(screen.getByPlaceholderText("Username"), "test"); + userEvent.type(screen.getByPlaceholderText("Email"), "test@email.com"); + userEvent.type(screen.getByPlaceholderText("Password"), "pass"); + userEvent.type(screen.getByPlaceholderText("Confirm Password"), "pass"); + userEvent.click(screen.getByText("Register")); + + await waitFor(() => { + expect( + screen.getByText("Error: An error occurred. Please retry") + ).toBeInTheDocument(); + }); + }); + it("should register new user", async () => { + render(); + userEvent.type(screen.getByPlaceholderText("Username"), `test${Date.now()}`); + userEvent.type(screen.getByPlaceholderText("Email"), `test${Date.now()}@email.com`); + userEvent.type(screen.getByPlaceholderText("Password"), "pass"); + userEvent.type(screen.getByPlaceholderText("Confirm Password"), "pass"); + userEvent.click(screen.getByText("Register")); + await waitFor(() => { + expect( + screen.getByText("Account created successfully!") + ).toBeInTheDocument(); + }); + }); + +}); diff --git a/frontend/src/components/RegistrationForm.tsx b/frontend/src/components/RegistrationForm.tsx new file mode 100644 index 00000000..6672a994 --- /dev/null +++ b/frontend/src/components/RegistrationForm.tsx @@ -0,0 +1,111 @@ +import React, { useState } from "react"; +import { AxiosError } from "axios"; +import { createUser } from "../api/apiService"; + +export interface RegistrationFormProps { + onRegister: () => void; +} + +const RegistrationForm: React.FC = ({ onRegister }) => { + const [form, setForm] = useState({ + username: "", + email: "", + password: "", + confirmPassword: "", + errorText: "", + }); + + const [registered, setRegistered] = useState(false); + + const handleChange = (e: React.ChangeEvent) => { + setForm({ ...form, [e.target.name]: e.target.value }); + }; + + const handleSubmit = async (e: React.FormEvent) => { + setForm({ ...form, errorText: "" }); + e.preventDefault(); + + // Check if passwords match + if (form.password !== form.confirmPassword) { + setForm({ ...form, errorText: "Error: Passwords do not match" }); + return; + } + + try { + await createUser({ + username: form.username, + email: form.email, + password: form.password, + }); + setRegistered(true); + onRegister(); + } catch (error) { + const errors = error as Error | AxiosError; + // Check if the error is an AxiosError + if (errors instanceof AxiosError) { + const axiosError = errors as AxiosError; + if (axiosError.response?.status === 400) { + setForm({ ...form, errorText: "Error: Invalid username or password" }); + } else { + setForm({ ...form, errorText: "Error: An error occurred. Please retry" }); + } + } + } + }; + return ( + + + Register new account + {form.errorText ? ( + {form.errorText} + ) : ( + Fill in the form to create a new account. + )} + {registered && Account created successfully!} + + + {!registered && ( + + + + + + Register + + Close + + + )} + + ); +}; + +export default RegistrationForm; diff --git a/frontend/src/components/RegistrationLink.test.tsx b/frontend/src/components/RegistrationLink.test.tsx new file mode 100644 index 00000000..e73347a2 --- /dev/null +++ b/frontend/src/components/RegistrationLink.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from "@testing-library/react"; +import RegistrationLink from "./RegistrationLink"; + +describe('RegistrationLink', () => { + it('should render the registration link', () => { + render(); + expect(screen.getByText('Welcome to the e-notes app')).toBeInTheDocument(); + expect(screen.getByText('Sign up for your free account')).toBeInTheDocument(); + expect(screen.getByText('Sign up')).toBeInTheDocument(); + }); + + it('should render the registration link with account created successfully message', () => { + render(); + expect(screen.getByText('Account created successfully!')).toBeInTheDocument(); + expect(screen.getByText('Please log in')).toBeInTheDocument(); + }); + } +); + diff --git a/frontend/src/components/RegistrationLink.tsx b/frontend/src/components/RegistrationLink.tsx new file mode 100644 index 00000000..995b26aa --- /dev/null +++ b/frontend/src/components/RegistrationLink.tsx @@ -0,0 +1,28 @@ +const RegistrationLink = (props: { onRegister: boolean }) => { + return ( + + + e-notes + + + {!props.onRegister ? ( + + Welcome to the e-notes app + Sign up for your free account + + Sign up + + + ) : ( + + Account created successfully! + Please log in + + + )} + + + ); +}; + +export default RegistrationLink; diff --git a/frontend/src/mocks/db.ts b/frontend/src/mocks/db.ts index f7fc84f6..6861ac21 100644 --- a/frontend/src/mocks/db.ts +++ b/frontend/src/mocks/db.ts @@ -8,8 +8,9 @@ export const db = factory({ content: (String), }, user: { - id: primaryKey(Number), + id: primaryKey(String), username: (String), password: (String), + email: (String), } }) \ No newline at end of file diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 981e09a7..ee6152f7 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -1,74 +1,116 @@ import { http, HttpResponse } from "msw"; import { db } from "./db"; - const BASE_URL = "http://localhost:5000/api"; -db.note.create({ id: 1, title: "Test Title Note 1", content: "Test Content 1" }); -db.note.create({ id: 2, title: "Test Title Note 2", content: "Test Content 2" }); -db.user.create({ id: 1, username: "test", password: "pass" }); +db.note.create({ + id: 1, + title: "Test Title Note 1", + content: "Test Content 1", +}); +db.note.create({ + id: 2, + title: "Test Title Note 2", + content: "Test Content 2", +}); +db.user.create({ id: '5d0efc58-1692-4a4f-94c7-82f1cddf3db9', username: "test", password: "pass" }); type Note = { - id: number; - title: string; - content: string; - }; + id: number; + title: string; + content: string; +}; type LoginRequestBody = { - username: string; - password: string; - }; + username: string; + password: string; +}; + +type UsersRequestBody = { + username: string; + password: string; + email: string; +}; const handlers = [ - http.post(`${BASE_URL}/login`, async ({ request }) => { - const { username, password } = await request.json() as LoginRequestBody; - const user = db.user.findFirst({ - where: { - username: { equals: username }, - password: { equals: password }, - }, - }); - if (user) { - return HttpResponse.json({ token: "test" }); - } else { - return HttpResponse.error(); - } - }), + http.post(`${BASE_URL}/login`, async ({ request }) => { + const { username, password } = (await request.json()) as LoginRequestBody; + const user = db.user.findFirst({ + where: { + username: { equals: username }, + password: { equals: password }, + }, + }); + if (user) { + return HttpResponse.json({ token: "test" }); + } else { + return HttpResponse.error(); + } + }), - http.get(`${BASE_URL}/notes`, () => { - return HttpResponse.json(db.note.getAll()); - }), - http.post(`${BASE_URL}/notes`, async ({ request }) => { - // Read the intercepted request body as JSON. - let newPost = await request.json() + http.post(`${BASE_URL}/users`, async ({ request }) => { + const { username, password, email } = + (await request.json()) as UsersRequestBody; - db.note.create({...newPost as Note, id: db.note.count() + 1}); + const user = db.user.findFirst({ + where: { + username: { equals: username }, + password: { equals: password }, + }, + }); - return HttpResponse.json(newPost, { status: 201 }) - }), - http.put(`${BASE_URL}/notes/:id`, async ({ request }) => { - const url = new URL(request.url) - // Get the id from the URL path - const match = url.pathname.match(/\/api\/notes\/(\d+)/); - const id = match ? match[1] : null; - const updatedNote = await request.json(); - db.note.update({ where: { - id: { equals: Number(id) }}, - data: updatedNote as Note - }); - return HttpResponse.json(updatedNote); - }), - http.delete(`${BASE_URL}/notes/:id`, async ({ request }) => { - const url = new URL(request.url) - // Get the id from the URL path - const match = url.pathname.match(/\/api\/notes\/(\d+)/); - const id = match ? match[1] : null; - db.note.delete({ where: { - id: { equals: Number(id) }} + if (user) { + return HttpResponse.error(); + } else { + const id = "83ebf8ec-fba5-451f-aece-011c4be5593e"; + db.user.create({ username, password, email, id}); + return HttpResponse.json({ + id, + username, + email, + createdAt: "2024-05-08T17:09:55.028Z", + updatedAt: "2024-05-08T17:09:55.028Z", }); - return HttpResponse.json({ status: "ok" }); - } - ), + } + }), + + http.get(`${BASE_URL}/notes`, () => { + return HttpResponse.json(db.note.getAll()); + }), + http.post(`${BASE_URL}/notes`, async ({ request }) => { + // Read the intercepted request body as JSON. + let newPost = await request.json(); + + db.note.create({ ...(newPost as Note), id: db.note.count() + 1 }); + + return HttpResponse.json(newPost, { status: 201 }); + }), + http.put(`${BASE_URL}/notes/:id`, async ({ request }) => { + const url = new URL(request.url); + // Get the id from the URL path + const match = url.pathname.match(/\/api\/notes\/(\d+)/); + const id = match ? match[1] : null; + const updatedNote = await request.json(); + db.note.update({ + where: { + id: { equals: Number(id) }, + }, + data: updatedNote as Note, + }); + return HttpResponse.json(updatedNote); + }), + http.delete(`${BASE_URL}/notes/:id`, async ({ request }) => { + const url = new URL(request.url); + // Get the id from the URL path + const match = url.pathname.match(/\/api\/notes\/(\d+)/); + const id = match ? match[1] : null; + db.note.delete({ + where: { + id: { equals: Number(id) }, + }, + }); + return HttpResponse.json({ status: "ok" }); + }), ]; const errorHandlers = [
{form.errorText}
Fill in the form to create a new account.
Account created successfully!
Welcome to the e-notes app
Sign up for your free account
Please log in