Skip to content

Commit

Permalink
feat(product-details-page): implement product details page (#108)
Browse files Browse the repository at this point in the history
- implement product details ui components
-write appropriate tests

[Delivers #101]

Co-authored-by: AMBROISE Muhayimana <107347030+ambroisegithub@users.noreply.github.com>
  • Loading branch information
jkarenzi and ambroisegithub authored Jul 20, 2024
1 parent b4e341a commit 4a355ef
Show file tree
Hide file tree
Showing 12 changed files with 972 additions and 15 deletions.
5 changes: 4 additions & 1 deletion src/__test__/categoriesSection.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router';
import CategoriesHome from '@/components/home/CategoriesHome';
import { store } from '@/app/store';

describe('App', () => {
it('Renders Home Categories Section', () => {
render(
<Provider store={store}>
<CategoriesHome />
<MemoryRouter>
<CategoriesHome />
</MemoryRouter>
</Provider>
);
expect(screen.getByText('New Arrivals')).toBeInTheDocument();
Expand Down
5 changes: 4 additions & 1 deletion src/__test__/home/categories.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import '@testing-library/jest-dom';
import { describe, it, expect, beforeEach } from 'vitest';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import { MemoryRouter } from 'react-router';
import Categories from '@/components/home/sidebar';

const mockStore = configureStore([]);
Expand Down Expand Up @@ -32,7 +33,9 @@ describe('Categories Component', () => {
it('renders Categories component with category details', () => {
render(
<Provider store={store}>
<Categories />
<MemoryRouter>
<Categories />
</MemoryRouter>
</Provider>
);

Expand Down
5 changes: 4 additions & 1 deletion src/__test__/home/productList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import '@testing-library/jest-dom';
import { describe, it, expect, beforeEach } from 'vitest';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import { MemoryRouter } from 'react-router';
import ProductsList from '@/components/home/productList';
import { Product } from '@/types/Product';
import User from '@/types/User';
Expand Down Expand Up @@ -86,7 +87,9 @@ describe('ProductsList Component', () => {
it('renders the ProductsList component with products', () => {
render(
<Provider store={store}>
<ProductsList focused="all" />
<MemoryRouter>
<ProductsList focused="all" />
</MemoryRouter>
</Provider>
);

Expand Down
166 changes: 166 additions & 0 deletions src/__test__/productDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { describe, it, expect, beforeEach } from 'vitest';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { Store, configureStore } from '@reduxjs/toolkit';
import { waitFor } from '@testing-library/dom';
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { MemoryRouter, Route, Routes } from 'react-router';
import productsReducer, {
fetchProductDetails,
} from '@/features/Products/ProductSlice';
import signInReducer from '@/features/Auth/SignInSlice';
import { AppDispatch, RootState } from '@/app/store';
import ProductDetails from '@/pages/ProductDetails';
import bestSellingReducer from '@/features/Popular/bestSellingProductSlice';

const mockProduct = {
id: 1,
name: 'Mock Product',
image: '/images/mock-product.jpg',
rating: 4.5,
salesPrice: 99.99,
regularPrice: 149.99,
totalQtySold: 25,
longDesc: 'This is a mock product used for testing purposes.',
shortDesc: 'This is a short description',
category: 'Electronics',
similarProducts: [],
reviews: [
{
id: 1,
user: {
id: 1,
firstName: 'new',
lastName: 'user',
picture: 'http://fake.png',
},
rating: 5,
content: 'excellent product',
},
],
gallery: [],
tags: ['testTag'],
vendor: {
name: 'Tester',
email: 'testervendor@gmail.com',
picture: 'https://fake.png',
},
};

const renderWithProviders = (
ui: React.ReactElement,
{
store = configureStore({
reducer: {
products: productsReducer,
bestSellingProducts: bestSellingReducer,
signIn: signInReducer,
},
}),
} = {}
) => {
return render(
<Provider store={store}>
<MemoryRouter initialEntries={['/product-details/1']}>
<Routes>
<Route path="/product-details/:id" element={ui} />
</Routes>
</MemoryRouter>
</Provider>
);
};

describe('ProductDetails Page', () => {
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(axios);
});

it('renders the ProductDetails page correctly', () => {
mock.onGet(`${import.meta.env.VITE_BASE_URL}/product/1`).reply(500);

renderWithProviders(<ProductDetails />);

expect(screen.getByText(/Product Details/i)).toBeInTheDocument();
});

it('fetches and displays product details', async () => {
mock
.onGet(`${import.meta.env.VITE_BASE_URL}/buyer/get_product/1`)
.reply(200, {
product: mockProduct,
});

renderWithProviders(<ProductDetails />);

await waitFor(() => {
expect(screen.getAllByText(/Mock Product/i)[0]).toBeInTheDocument();
expect(screen.getByText(/testTag/i)).toBeInTheDocument();
expect(screen.getByText(/\$99.99/i)).toBeInTheDocument();
expect(screen.getByText(/\$149.99/i)).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('25')).toBeInTheDocument();
expect(
screen.getByText(/This is a mock product used for testing purposes./i)
).toBeInTheDocument();
expect(
screen.getByText(/This is a short description/i)
).toBeInTheDocument();
});
});

it('shows error message on failed fetch', async () => {
mock.onGet(`${import.meta.env.VITE_BASE_URL}/product/1`).reply(500, {
message: 'Internal Server Error',
});

renderWithProviders(<ProductDetails />);

await waitFor(() => {
expect(
screen.getByText(/Failed to load product details/i)
).toBeInTheDocument();
});
});
});

describe('Product Details async action', () => {
let store: Store;
let mock: MockAdapter;

beforeEach(() => {
mock = new MockAdapter(axios);
store = configureStore({
reducer: {
products: productsReducer,
},
});
});

it('should handle fetchProductDetails.fulfilled', async () => {
mock
.onGet(`${import.meta.env.VITE_BASE_URL}/buyer/get_product/1`)
.reply(200, {
product: mockProduct,
});

const { dispatch }: { dispatch: AppDispatch } = store;
await dispatch(fetchProductDetails(1));
const state = (store.getState() as RootState).products;
expect(state.productDetailsLoading).toBe(false);
expect(state.productDetails).toEqual(mockProduct);
});

it('should handle fetchProductDetails.rejected', async () => {
mock
.onGet(`${import.meta.env.VITE_BASE_URL}/buyer/get_product/1`)
.reply(500);

const { dispatch }: { dispatch: AppDispatch } = store;
await dispatch(fetchProductDetails(1));
const state = (store.getState() as RootState).products;
expect(state.productDetailsLoading).toBe(false);
expect(state.productDetails).toBeNull();
});
});
4 changes: 2 additions & 2 deletions src/__test__/wishlist.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,8 @@ describe('WishlistCard', () => {

expect(screen.getByText(/Product name/i)).toBeInTheDocument();
expect(screen.getByText(/In Stock/i)).toBeInTheDocument();
expect(screen.getByText(/\$500/i)).toBeInTheDocument();
expect(screen.getByText(/\$700/i)).toBeInTheDocument();
expect(screen.getByText(/\$230/i)).toBeInTheDocument();
expect(screen.getByText(/\$280/i)).toBeInTheDocument();
});
});

Expand Down
20 changes: 20 additions & 0 deletions src/components/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ReactNode } from 'react';
import { Navigate } from 'react-router';
import { useAppSelector } from '@/app/hooks';

interface ProtectedRouteProps {
children: ReactNode;
roles: string[];
}

function ProtectedRoute({ children, roles }: ProtectedRouteProps) {
const user = useAppSelector((state) => state.signIn.user);

if (user && user.userType && roles.includes(user.userType.name)) {
return children;
}

return <Navigate to="/signIn" />;
}

export default ProtectedRoute;
8 changes: 6 additions & 2 deletions src/components/WishlistCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,12 @@ function WishlistCard({ product }: { product: Product }) {
)}
</div>
<div className="flex gap-4 items-center">
<span className="text-red-700 font-bold text-lg">${500}</span>
<span className="line-through text-gray-500">${700}</span>
<span className="text-red-700 font-bold text-lg">
${product.salesPrice}
</span>
<span className="line-through text-gray-500">
${product.regularPrice}
</span>
</div>
<Button title="Add to Cart" />
</div>
Expand Down
10 changes: 8 additions & 2 deletions src/components/home/ProductCard.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useNavigate } from 'react-router';
import { useAppDispatch, useAppSelector } from '@/app/hooks';
import { addToWishlist } from '@/features/Products/ProductSlice';
import { Product } from '@/types/Product';
Expand All @@ -7,6 +8,7 @@ interface ProductCardProps {
}

function ProductCard({ product }: ProductCardProps) {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { token } = useAppSelector((state) => state.signIn);
return (
Expand All @@ -31,10 +33,14 @@ function ProductCard({ product }: ProductCardProps) {
}}
>
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-800">
<button
type="button"
className="text-lg font-semibold text-gray-800 cursor-pointer"
onClick={() => navigate(`/product-details/${product.id}`)}
>
{product.name.substring(0, 17)}
{product.name.length > 17 && '...'}
</h3>
</button>
<svg
onClick={() => dispatch(addToWishlist({ token, id: product.id }))}
xmlns="http://www.w3.org/2000/svg"
Expand Down
49 changes: 44 additions & 5 deletions src/features/Products/ProductSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ interface Payload {
data: Product[];
}

interface IProduct extends Product {
similarProducts: Product[];
totalQtySold: number;
}

interface SearchParams {
keyword?: string;
category?: number[];
Expand All @@ -33,11 +38,30 @@ export const fetchProducts = createAsyncThunk(

export const fetchRecommendedProducts = createAsyncThunk<Product[]>(
'products/fetchRecommendedProducts',
async () => {
const response = await axios.get(
`${import.meta.env.VITE_BASE_URL}/product/recommended`
);
return response.data.data;
async (_, thunkAPI) => {
try {
const response = await axios.get(
`${import.meta.env.VITE_BASE_URL}/product/recommended`
);
return response.data.data;
} catch (err) {
return thunkAPI.rejectWithValue('An error occured');
}
}
);

export const fetchProductDetails = createAsyncThunk<IProduct, number>(
'products/fetchProductDetails',
async (id, thunkAPI) => {
try {
const response = await axios.get(
`${import.meta.env.VITE_BASE_URL}/buyer/get_product/${id}`
);

return response.data.product;
} catch (err) {
return thunkAPI.rejectWithValue('An error occured');
}
}
);

Expand Down Expand Up @@ -165,6 +189,8 @@ interface ProductsState {
total: number;
wishlistProducts: Product[];
wishlistLoading: boolean;
productDetailsLoading: boolean;
productDetails: IProduct | null;
}

const initialState: ProductsState = {
Expand Down Expand Up @@ -204,6 +230,8 @@ const initialState: ProductsState = {
recommendedProducts: [],
wishlistProducts: [],
wishlistLoading: false,
productDetails: null,
productDetailsLoading: false,
total: 0,
};

Expand Down Expand Up @@ -306,6 +334,17 @@ const productsSlice = createSlice({
.addCase(removeFromWishlist.rejected, (state, action) => {
state.wishlistLoading = false;
showErrorToast(action.payload as string);
})
.addCase(fetchProductDetails.pending, (state) => {
state.productDetailsLoading = true;
})
.addCase(fetchProductDetails.fulfilled, (state, action) => {
state.productDetailsLoading = false;
state.productDetails = action.payload;
})
.addCase(fetchProductDetails.rejected, (state, action) => {
state.productDetailsLoading = false;
showErrorToast(action.payload as string);
});
},
});
Expand Down
Loading

0 comments on commit 4a355ef

Please sign in to comment.