Skip to content

Commit 701fbe7

Browse files
authored
Merge pull request #21 from atlp-rwanda/ft-buyer-feedback/reviews-#187419144
#187419144 A buyer should be able to leave feedback/reviews for products
2 parents b2fcb6c + 5ba065a commit 701fbe7

File tree

9 files changed

+736
-12
lines changed

9 files changed

+736
-12
lines changed

src/__test__/reviewSlice.test.tsx

+215
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { configureStore, Tuple } from "@reduxjs/toolkit";
2+
import thunk from "redux-thunk";
3+
import MockAdapter from "axios-mock-adapter";
4+
5+
import api from "../redux/api/api";
6+
import reviewSlice, {
7+
fetchReviews,
8+
addReview,
9+
deleteReview,
10+
updateReview,
11+
} from "../redux/reducers/reviewSlice";
12+
13+
const mockApi = new MockAdapter(api);
14+
const mockStore = (initialState) =>
15+
configureStore({
16+
reducer: {
17+
// @ts-ignore
18+
review: reviewSlice,
19+
},
20+
preloadedState: initialState,
21+
});
22+
23+
describe("Review Slice Thunks", () => {
24+
let store;
25+
26+
beforeEach(() => {
27+
mockApi.reset();
28+
store = mockStore({
29+
review: {
30+
reviews: [],
31+
isLoading: false,
32+
error: null,
33+
},
34+
});
35+
});
36+
37+
it("should handle fetchReviews pending", async () => {
38+
mockApi.onGet("/products/productId/reviews").reply(200, []);
39+
40+
store.dispatch(fetchReviews({ productId: "productId" }));
41+
expect(store.getState().review.isLoading).toBe(true);
42+
});
43+
it("should handle fetchReviews fulfilled", async () => {
44+
const mockData = [
45+
{
46+
id: "1",
47+
productId: "productId",
48+
user: { id: 1, name: "John" },
49+
rating: "5",
50+
feedback: "Great!",
51+
},
52+
];
53+
mockApi
54+
.onGet("/products/productId/reviews")
55+
.reply(200, { reviewProduct: mockData });
56+
57+
await store.dispatch(fetchReviews({ productId: "productId" }));
58+
59+
expect(store.getState().review.reviews).toEqual(mockData);
60+
expect(store.getState().review.isLoading).toBe(false);
61+
expect(store.getState().review.error).toBe(null);
62+
});
63+
64+
it("should handle fetchReviews rejected", async () => {
65+
mockApi.onGet("/products/productId/reviews").reply(500);
66+
67+
await store.dispatch(fetchReviews({ productId: "productId" }));
68+
69+
expect(store.getState().review.isLoading).toBe(false);
70+
expect(store.getState().review.error).toBe(
71+
"Request failed with status code 500",
72+
);
73+
});
74+
75+
it("should handle fetchReviews axios error", async () => {
76+
mockApi
77+
.onGet("/products/productId/reviews")
78+
.reply(500, { message: "Server error" });
79+
80+
await store.dispatch(fetchReviews({ productId: "productId" }));
81+
expect(store.getState().review.isLoading).toBe(false);
82+
expect(store.getState().review.error).toBe(
83+
"Request failed with status code 500",
84+
);
85+
});
86+
87+
it("should handle addReview fulfilled", async () => {
88+
const mockData = {
89+
id: "3",
90+
productId: "productId",
91+
user: { id: 3, name: "bwiza" },
92+
rating: "4",
93+
feedback: "Good!",
94+
};
95+
mockApi.onPost("/products/productId/reviews").reply(200, mockData);
96+
97+
await store.dispatch(
98+
addReview({ productId: "productId", rating: "4", feedback: "loop it" }),
99+
);
100+
expect(store.getState().review.reviews).toContainEqual(mockData);
101+
expect(store.getState().review.isLoading).toBe(false);
102+
expect(store.getState().review.error).toBe(null);
103+
});
104+
105+
it("should handle addReview axios error", async () => {
106+
mockApi
107+
.onPost("/products/prod1/reviews")
108+
.reply(500, { message: "Add review failed" });
109+
110+
await store.dispatch(
111+
addReview({ productId: "prod1", rating: "4", feedback: "Good!" }),
112+
);
113+
114+
expect(store.getState().review.isLoading).toBe(false);
115+
expect(store.getState().review.error).toBeTruthy();
116+
});
117+
118+
it("should handle deleteReview fulfilled", async () => {
119+
const initialState = {
120+
review: {
121+
reviews: [
122+
{
123+
id: "2",
124+
productId: "productId",
125+
user: { id: 2, name: "bwiza" },
126+
rating: "4",
127+
feedback: "Good!",
128+
},
129+
],
130+
isLoading: false,
131+
error: null,
132+
},
133+
};
134+
store = mockStore(initialState);
135+
mockApi.onDelete("/products/productId/reviews").reply(200);
136+
// @ts-ignore
137+
await store.dispatch(deleteReview({ productId: "productId", id: "2" }));
138+
expect(store.getState().review.reviews).toEqual([]);
139+
expect(store.getState().review.isLoading).toBe(false);
140+
expect(store.getState().review.error).toBe(null);
141+
});
142+
it("should handle updateReview fulfilled", async () => {
143+
const initialState = {
144+
review: {
145+
reviews: [
146+
{
147+
id: "2",
148+
productId: "productId",
149+
user: { id: 2, name: "bwiza" },
150+
rating: "4",
151+
feedback: "loop it",
152+
},
153+
],
154+
isLoading: false,
155+
error: null,
156+
},
157+
};
158+
store = mockStore(initialState);
159+
const updatedReview = {
160+
id: "2",
161+
productId: "productId",
162+
user: { id: 2, name: "Jane" },
163+
rating: "5",
164+
feedback: "loop througth!",
165+
};
166+
mockApi.onPatch("/products/productId/reviews").reply(200, updatedReview);
167+
await store.dispatch(
168+
updateReview({
169+
productId: "productId",
170+
id: 2,
171+
rating: "5",
172+
feedback: "loop througth!",
173+
}),
174+
);
175+
176+
expect(store.getState().review.reviews).toContainEqual(updatedReview);
177+
expect(store.getState().review.isLoading).toBe(false);
178+
expect(store.getState().review.error).toBe(null);
179+
});
180+
181+
it("should handle deleteReview axios error", async () => {
182+
mockApi
183+
.onDelete("/products/prod1/reviews")
184+
.reply(500, { message: "Delete review failed" });
185+
// @ts-ignore
186+
await store.dispatch(deleteReview({ productId: "prod1", id: "2" }));
187+
expect(store.getState().review.isLoading).toBe(false);
188+
expect(store.getState().review.error).toBeTruthy();
189+
});
190+
191+
it("should handle updateReview axios error", async () => {
192+
mockApi
193+
.onPatch("/products/prod1/reviews")
194+
.reply(500, { message: "Update review failed" });
195+
// @ts-ignore
196+
await store.dispatch(
197+
updateReview({
198+
productId: "prod1",
199+
id: 2,
200+
rating: "5",
201+
feedback: "Excellent!",
202+
}),
203+
);
204+
expect(store.getState().review.isLoading).toBe(false);
205+
expect(store.getState().review.error).toBeTruthy();
206+
});
207+
208+
it("should handle axios network error", async () => {
209+
mockApi.onGet("/products/prod1/reviews").networkError();
210+
211+
await store.dispatch(fetchReviews({ productId: "prod1" }));
212+
expect(store.getState().review.isLoading).toBe(false);
213+
expect(store.getState().review.error).toBe("Network Error");
214+
});
215+
});

src/components/cards/ProductCard.tsx

+29-3
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
} from "../../redux/reducers/cartSlice";
1717
import Warning from "../common/notify/Warning";
1818
import { RootState } from "../../redux/store";
19-
import { RegisterError } from "../../../type";
19+
import { RegisterError, Review } from "../../../type";
20+
import api from "../../redux/api/api";
2021

2122
interface IProductCardProps {
2223
product: IProduct;
@@ -25,6 +26,7 @@ interface IProductCardProps {
2526
const ProductCard: React.FC<IProductCardProps> = ({ product }) => {
2627
const [isHovered, setIsHovered] = useState(false);
2728
const [isLoading, setIsLoading] = useState(false);
29+
const [reviews, setReviews] = useState<Review[]>([]);
2830
const dispatch = useAppDispatch();
2931

3032
const formatPrice = (price: number) => {
@@ -36,6 +38,28 @@ const ProductCard: React.FC<IProductCardProps> = ({ product }) => {
3638
}
3739
return `${(price / 1000000).toFixed(1)}M`;
3840
};
41+
42+
useEffect(() => {
43+
const fetchReviews = async () => {
44+
try {
45+
const response = await api.get(`/products/${product.id}/reviews`);
46+
if (!response) {
47+
throw new Error("Failed to fetch reviews");
48+
}
49+
const data = await response.data;
50+
setReviews(data.reviewProduct);
51+
} catch (error) {
52+
console.error("Error fetching reviews:", error);
53+
}
54+
};
55+
56+
fetchReviews();
57+
}, [product.id]);
58+
const total = reviews
59+
? reviews.reduce((sum, review) => sum + (review.rating, 10), 0)
60+
/ reviews.length
61+
: 0;
62+
3963
const handleRemove = async (productId: number) => {
4064
try {
4165
// @ts-ignore
@@ -159,14 +183,16 @@ const ProductCard: React.FC<IProductCardProps> = ({ product }) => {
159183
{formatPrice(product.price)}
160184
</p>
161185
<Rating
162-
value={4}
186+
value={total}
163187
color="orange"
164188
disabled
165189
size="small"
166190
data-testid="rating"
167191
/>
168192
<p className="text-[10px]" data-testid="review">
169-
(56)
193+
(
194+
{reviews ? reviews.length : 0}
195+
)
170196
</p>
171197
</div>
172198
</div>

src/pages/ProductDetails.tsx

+17-9
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@ import { useParams } from "react-router-dom";
1414
import { MdChat, MdCurrencyExchange } from "react-icons/md";
1515
import { IoMdHeartEmpty } from "react-icons/io";
1616
import { BsChatRightText } from "react-icons/bs";
17+
import { useSelector } from "react-redux";
1718

1819
import { useFetchSingleProduct } from "../libs/queries";
1920
import ProductDetailSkleton from "../components/skeletons/ProductDetailSkleton";
2021
import { IProduct, prod } from "../types";
2122
import api from "../redux/api/api";
2223
import RelatedProducts from "../components/common/related-products/RelatedProducts";
24+
import { fetchReviews } from "../redux/reducers/reviewSlice";
25+
26+
import ReviewsList from "./ReviewList";
2327

2428
const ProductDetails: React.FC = () => {
2529
const [mainImage, setMainImage] = useState<string | null>(null);
@@ -29,15 +33,12 @@ const ProductDetails: React.FC = () => {
2933
const [isLoading, setIsLoading] = useState(false);
3034
const [activeImage, setActiveImage] = useState(0);
3135
const { id } = useParams();
32-
36+
const { reviews } = useSelector((state: RootState) => state.review);
3337
useEffect(() => {
3438
setIsLoading(true);
35-
3639
const fetch = async () => {
3740
try {
3841
const res = await api.get(`/products/${id}`);
39-
40-
console.log(res.data.product[0]);
4142
res.status === 404;
4243
setProduct(res.data.product[0]);
4344
setIsLoading(false);
@@ -47,7 +48,6 @@ const ProductDetails: React.FC = () => {
4748
setIsloading(false);
4849
}
4950
};
50-
5151
fetch();
5252
}, [id]);
5353

@@ -58,18 +58,21 @@ const ProductDetails: React.FC = () => {
5858
if (isLoading) {
5959
return <ProductDetailSkleton />;
6060
}
61-
6261
const handleImageClick = (image: string) => {
6362
setMainImage(image);
6463
};
65-
6664
const isDiscounted = product && product?.discount > 0;
6765
const discount = product?.discount;
6866

6967
const discountedAmount = product && (product!.price * product?.discount) / 100;
7068

7169
const priceAfterDiscount = discountedAmount && product?.price - discountedAmount;
7270

71+
const totalRatings = reviews
72+
? reviews.reduce((sum, review) => sum + parseInt(review.rating, 10), 0)
73+
/ reviews.length
74+
: 0;
75+
7376
const handleImage = (url: string, index: number) => {
7477
setMainImage(url);
7578
setActiveImage(index);
@@ -145,8 +148,12 @@ const ProductDetails: React.FC = () => {
145148
<Typography variant="h5">{product?.name}</Typography>
146149
<div className="flex items-center gap-3 h-[44px]">
147150
<div className="flex items-center gap-1 h-[44px]">
148-
<Rating value={4} disabled size="small" />
149-
<p className="text-[10px] text-[#6085A5]">(56 Reviews)</p>
151+
<Rating value={totalRatings} disabled size="small" />
152+
<p className="text-[10px] text-[#6085A5]">
153+
(
154+
{reviews ? reviews.length : 0}
155+
)
156+
</p>
150157
<p className="text-[10px] text-[#6085A5]">
151158
(
152159
{product?.stockQuantity}
@@ -265,6 +272,7 @@ const ProductDetails: React.FC = () => {
265272
/>
266273
</>
267274
)}
275+
<ReviewsList productId={id} />
268276
</div>
269277
);
270278
};

0 commit comments

Comments
 (0)