Skip to content

Commit

Permalink
Merge pull request #29 from Code-Hammers/user-profile
Browse files Browse the repository at this point in the history
User profile
  • Loading branch information
brok3turtl3 authored Nov 30, 2023
2 parents 975427e + 1da2abf commit e116fbf
Show file tree
Hide file tree
Showing 8 changed files with 251 additions and 21 deletions.
2 changes: 2 additions & 0 deletions client/src/AuthenticatedApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -25,6 +26,7 @@ const AuthenticatedApp = () => {
<Routes>
<Route path="main" element={<MainPage />} />
<Route path="/profiles" element={<Profiles />} />
<Route path="/profile" element={<Profile />} />
<Route path="/forums" element={<Forums />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
Expand Down
7 changes: 6 additions & 1 deletion client/src/app/store.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
55 changes: 45 additions & 10 deletions client/src/components/Banner/Banner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(() => {
Expand All @@ -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(<Banner />);
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(<Banner />);
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(<Banner />);
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(<Banner />);
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("/");
});
});
47 changes: 37 additions & 10 deletions client/src/components/Banner/Banner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="bg-rose-300 p-4 md:p-6 flex items-center w-full justify-between">
{" "}
<img src={logo} alt="Code Hammers Logo" className="h-12 md:h-16" />
<div className="flex-grow flex justify-center">
<h1 className="text-2xl md:text-4xl font-semibold text-teal-600">
Code Hammers
</h1>{" "}
</h1>
</div>
<div className="relative">
<button
onClick={() => setShowDropdown(!showDropdown)}
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
>
Options
</button>
{showDropdown && (
<div className="absolute right-0 mt-2 py-2 w-48 bg-white rounded-md shadow-xl z-20">
<a
href="#!"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={goToProfile}
>
Go to Profile
</a>
<a
href="#!"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={handleLogout}
>
Logout
</a>
</div>
)}
</div>
<button
onClick={handleLogout}
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
>
Logout
</button>
</div>
);
};
Expand Down
62 changes: 62 additions & 0 deletions client/src/features/userProfile/userProfileSlice.ts
Original file line number Diff line number Diff line change
@@ -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;
54 changes: 54 additions & 0 deletions client/src/pages/Profile/Profile.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Profile />);
const profileTitle = screen.getByText("Profile");
expect(profileTitle).toBeInTheDocument();
});

it("dispatches fetchUserProfile on component mount", () => {
render(<Profile />);
expect(mockDispatch).toHaveBeenCalledWith(fetchUserProfile(mockUser._id));
});

it("displays the user's ID", () => {
render(<Profile />);
const userIdDisplay = screen.getByText(mockUser._id);
expect(userIdDisplay).toBeInTheDocument();
});
});
22 changes: 22 additions & 0 deletions client/src/pages/Profile/Profile.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen bg-gray-100 flex flex-col items-center justify-center">
<h1 className="text-4xl font-extrabold mb-4">Profile</h1>
<h2 className="text-4xl font-extrabold mb-4">{user._id}</h2>
<h2 className="text-4xl font-extrabold mb-4">{userProfile?.bio}</h2>
</div>
);
};

export default Profile;
23 changes: 23 additions & 0 deletions client/src/pages/Profiles/__snapshots__/Profiles.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`MainPage Component renders correctly 1`] = `
[
<div
className="min-h-screen bg-gray-100 flex flex-col items-center justify-center"
>
<h1
className="text-4xl font-extrabold mb-4"
>
PROFILES
</h1>
</div>,
<div>
<div>
User1
</div>
<div>
User2
</div>
</div>,
]
`;

0 comments on commit e116fbf

Please sign in to comment.