Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User profile #29

Merged
merged 4 commits into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>,
]
`;