diff --git a/client/src/AuthenticatedApp.tsx b/client/src/AuthenticatedApp.tsx index d375c3f..38b65b9 100644 --- a/client/src/AuthenticatedApp.tsx +++ b/client/src/AuthenticatedApp.tsx @@ -6,6 +6,7 @@ import { useAppSelector } from "./app/hooks"; import MainPage from "./pages/MainPage/MainPage"; import Forums from "./pages/Forums/Forums"; import Profiles from "./pages/Profiles/Profiles"; +import Profile from "./pages/Profile/Profile"; import NotFoundPage from "./pages/NotFoundPage/NotFoundPage"; import { useNavigate } from "react-router-dom"; @@ -25,6 +26,7 @@ const AuthenticatedApp = () => { } /> } /> + } /> } /> } /> diff --git a/client/src/app/store.ts b/client/src/app/store.ts index b94adb9..d2a17f0 100644 --- a/client/src/app/store.ts +++ b/client/src/app/store.ts @@ -1,9 +1,14 @@ import { configureStore } from "@reduxjs/toolkit"; import userReducer from "../features/user/userSlice"; import profilesReducer from "../features/profiles/profilesSlice"; +import userProfileReducer from "../features/userProfile/userProfileSlice"; export const store = configureStore({ - reducer: { user: userReducer, profiles: profilesReducer }, + reducer: { + user: userReducer, + profiles: profilesReducer, + userProfile: userProfileReducer, + }, }); export type AppDispatch = typeof store.dispatch; diff --git a/client/src/components/Banner/Banner.test.tsx b/client/src/components/Banner/Banner.test.tsx index 38a2d58..f2ed51a 100644 --- a/client/src/components/Banner/Banner.test.tsx +++ b/client/src/components/Banner/Banner.test.tsx @@ -2,13 +2,13 @@ import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom"; import Banner from "./Banner"; -import { useAppDispatch } from "../../app/hooks"; +import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { logout } from "../../features/user/userSlice"; import { useNavigate } from "react-router-dom"; -// Mock the hooks and external dependencies jest.mock("../../app/hooks", () => ({ useAppDispatch: jest.fn(), + useAppSelector: jest.fn(), })); jest.mock("../../features/user/userSlice", () => ({ logout: jest.fn(), @@ -20,10 +20,19 @@ jest.mock("react-router-dom", () => ({ describe("Banner Component", () => { const mockDispatch = jest.fn(); const mockNavigate = jest.fn(); + const mockUserData = { + id: "123", + name: "John Doe", + }; beforeEach(() => { (useAppDispatch as jest.Mock).mockReturnValue(mockDispatch); (useNavigate as jest.Mock).mockReturnValue(mockNavigate); + (useAppSelector as jest.Mock).mockImplementation((selector) => + selector({ + user: { userData: mockUserData }, + }) + ); }); afterEach(() => { @@ -42,18 +51,44 @@ describe("Banner Component", () => { expect(title).toBeInTheDocument(); }); - it("renders the logout button and handles logout on click", () => { + it("renders the Options button", () => { render(); - const logoutButton = screen.getByRole("button", { name: "Logout" }); - expect(logoutButton).toBeInTheDocument(); + const optionsButton = screen.getByRole("button", { name: "Options" }); + expect(optionsButton).toBeInTheDocument(); + }); + + it("opens the dropdown and shows options when Options button is clicked", () => { + render(); + const optionsButton = screen.getByRole("button", { name: "Options" }); + fireEvent.click(optionsButton); - // Simulate a click on the logout button - fireEvent.click(logoutButton); + const profileOption = screen.getByText("Go to Profile"); + const logoutOption = screen.getByText("Logout"); - // Expect the logout action to be dispatched - expect(mockDispatch).toHaveBeenCalledWith(logout()); + expect(profileOption).toBeInTheDocument(); + expect(logoutOption).toBeInTheDocument(); + }); + + it("handles navigation to Profile on clicking Go to Profile", () => { + render(); + const optionsButton = screen.getByRole("button", { name: "Options" }); + fireEvent.click(optionsButton); - // Expect navigation to have been called with the root path + const profileOption = screen.getByText("Go to Profile"); + fireEvent.click(profileOption); + + expect(mockNavigate).toHaveBeenCalledWith("profile"); + }); + + it("handles logout on clicking Logout", () => { + render(); + const optionsButton = screen.getByRole("button", { name: "Options" }); + fireEvent.click(optionsButton); + + const logoutOption = screen.getByText("Logout"); + fireEvent.click(logoutOption); + + expect(mockDispatch).toHaveBeenCalledWith(logout()); expect(mockNavigate).toHaveBeenCalledWith("/"); }); }); diff --git a/client/src/components/Banner/Banner.tsx b/client/src/components/Banner/Banner.tsx index a39e57e..26da12c 100644 --- a/client/src/components/Banner/Banner.tsx +++ b/client/src/components/Banner/Banner.tsx @@ -1,32 +1,59 @@ -import React from "react"; +import React, { useState } from "react"; import logo from "../../assets/hammer.png"; -import { useAppDispatch } from "../../app/hooks"; +import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { logout } from "../../features/user/userSlice"; import { useNavigate } from "react-router-dom"; const Banner = (): JSX.Element => { + const user = useAppSelector((state) => state.user.userData); const dispatch = useAppDispatch(); const navigate = useNavigate(); + const [showDropdown, setShowDropdown] = useState(false); const handleLogout = () => { dispatch(logout()); navigate("/"); + //TODO CLEAR ALL STATE + }; + + const goToProfile = () => { + navigate("profile"); + setShowDropdown(false); }; return (
- {" "} Code Hammers Logo

Code Hammers -

{" "} + +
+
+ + {showDropdown && ( + + )}
-
); }; diff --git a/client/src/features/userProfile/userProfileSlice.ts b/client/src/features/userProfile/userProfileSlice.ts new file mode 100644 index 0000000..fdd4f6a --- /dev/null +++ b/client/src/features/userProfile/userProfileSlice.ts @@ -0,0 +1,62 @@ +import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; +import axios from "axios"; +import { IProfile } from "../../../types/profile"; + +interface ProfileState { + profile: IProfile | null; //TODO ADD PROPER TYPING ONCE OBJECT IS FINALIZED + status: "idle" | "loading" | "failed"; + error: string | null; +} + +const initialState: ProfileState = { + profile: null, + status: "idle", + error: null, +}; + +//TODO REVIEW TYPING +export const fetchUserProfile = createAsyncThunk( + "profile/fetchUserProfile", + async (userID: string, thunkAPI) => { + try { + const response = await axios.get(`/api/profiles/${userID}`); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + return thunkAPI.rejectWithValue( + error.response?.data || "Error fetching profiles" + ); + } + return thunkAPI.rejectWithValue("An unexpected error occurred"); + } + } +); + +const userProfileSlice = createSlice({ + name: "profile", + initialState, + reducers: { + resetProfileState(state) { + state.profile = null; + state.status = "idle"; + state.error = null; + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchUserProfile.pending, (state) => { + state.status = "loading"; + }) + .addCase(fetchUserProfile.fulfilled, (state, action) => { + state.profile = action.payload; + state.status = "idle"; + }) + .addCase(fetchUserProfile.rejected, (state, action) => { + state.status = "failed"; + //TODO state.error = action.payload as string; WHAT WOULD PAYLOAD LOOK LIKE HERE? + //TODO HOOK UP GOOD ERROR INFO TO ERROR STATE + }); + }, +}); + +export default userProfileSlice.reducer; diff --git a/client/src/pages/Profile/Profile.test.tsx b/client/src/pages/Profile/Profile.test.tsx new file mode 100644 index 0000000..edd9cd3 --- /dev/null +++ b/client/src/pages/Profile/Profile.test.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import Profile from "./Profile"; +import { useAppSelector, useAppDispatch } from "../../app/hooks"; +import { fetchUserProfile } from "../../features/userProfile/userProfileSlice"; + +jest.mock("../../app/hooks", () => ({ + useAppDispatch: jest.fn(), + useAppSelector: jest.fn(), +})); + +jest.mock("../../features/userProfile/userProfileSlice", () => ({ + fetchUserProfile: jest.fn(), +})); + +describe("Profile Component", () => { + const mockDispatch = jest.fn(); + const mockUser = { + _id: "12345", + name: "John Doe", + }; + //TODO MOCK BETTER USERPROFILE DATA + beforeEach(() => { + (useAppDispatch as jest.Mock).mockReturnValue(mockDispatch); + (useAppSelector as jest.Mock).mockImplementation((selector) => + selector({ + user: { userData: mockUser }, + userProfile: { profile: null }, + }) + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("renders the Profile component", () => { + render(); + const profileTitle = screen.getByText("Profile"); + expect(profileTitle).toBeInTheDocument(); + }); + + it("dispatches fetchUserProfile on component mount", () => { + render(); + expect(mockDispatch).toHaveBeenCalledWith(fetchUserProfile(mockUser._id)); + }); + + it("displays the user's ID", () => { + render(); + const userIdDisplay = screen.getByText(mockUser._id); + expect(userIdDisplay).toBeInTheDocument(); + }); +}); diff --git a/client/src/pages/Profile/Profile.tsx b/client/src/pages/Profile/Profile.tsx new file mode 100644 index 0000000..d9a8300 --- /dev/null +++ b/client/src/pages/Profile/Profile.tsx @@ -0,0 +1,22 @@ +import React, { useEffect } from "react"; +import { useAppSelector, useAppDispatch } from "../../app/hooks"; +import { fetchUserProfile } from "../../features/userProfile/userProfileSlice"; + +const Profile = (): JSX.Element => { + const dispatch = useAppDispatch(); + const userProfile = useAppSelector((state) => state.userProfile.profile); + const user = useAppSelector((state) => state.user.userData); + + useEffect(() => { + dispatch(fetchUserProfile(user._id)); + }, [dispatch]); + return ( +
+

Profile

+

{user._id}

+

{userProfile?.bio}

+
+ ); +}; + +export default Profile; diff --git a/client/src/pages/Profiles/__snapshots__/Profiles.test.tsx.snap b/client/src/pages/Profiles/__snapshots__/Profiles.test.tsx.snap new file mode 100644 index 0000000..afd48d2 --- /dev/null +++ b/client/src/pages/Profiles/__snapshots__/Profiles.test.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MainPage Component renders correctly 1`] = ` +[ +
+

+ PROFILES +

+
, +
+
+ User1 +
+
+ User2 +
+
, +] +`;