Skip to content

Commit

Permalink
Optimize upvote and downvote colors (#432)
Browse files Browse the repository at this point in the history
* optimize votes to avoid re-fetching votes for all reviews

* implement aggregateDocuments function to get reviews from mongo, and store user vote in review object

* remove unused getVoteColors function, voteColors state, and getColors function

* remove unused variables deltaScore and MouseEvent e in upvote and downvote functions

* move score update function to Review.tsx, create new FeaturedReviewData type, remove unused types and api routes for vote colors

* move updateScore function into SubReview

* change reviewID type to String in mongoose Vote schema

* make userVote property optional in ReviewData type

* fix type errors
  • Loading branch information
advayanand committed May 29, 2024
1 parent 462fff3 commit e800308
Show file tree
Hide file tree
Showing 7 changed files with 137 additions and 146 deletions.
107 changes: 60 additions & 47 deletions api/src/controllers/reviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,66 @@ router.get('/', async function (req, res) {
}
}

const reviews = await Review.find(query);
const pipeline = [
{
$match: query,
},
{
$addFields: {
_id: {
$toString: '$_id',
},
},
},
{
$lookup: {
from: 'votes',
let: {
cmpUserID: req.session.passport?.user.id,
cmpReviewID: '$_id',
},
pipeline: [
{
$match: {
$expr: {
$and: [
{
$eq: ['$$cmpUserID', '$userID'],
},
{
$eq: ['$$cmpReviewID', '$reviewID'],
},
],
},
},
},
],
as: 'userVote',
},
},
{
$addFields: {
userVote: {
$cond: {
if: {
$ne: ['$userVote', []],
},
then: {
$getField: {
field: 'score',
input: {
$first: '$userVote',
},
},
},
else: 0,
},
},
},
},
];

const reviews = await Review.aggregate(pipeline);
if (reviews) {
res.json(reviews);
} else {
Expand Down Expand Up @@ -213,7 +272,6 @@ router.patch('/vote', async function (req, res) {
res.json({ deltaScore: deltaScore });
} else {
//no old vote, just add in new vote data
console.log(`Voting Review ${id} with delta ${deltaScore}`);
await Review.updateOne({ _id: id }, { $inc: { score: deltaScore } });
//sends in vote
await new Vote({ userID: req.session.passport.user.id, reviewID: id, score: deltaScore }).save();
Expand All @@ -223,51 +281,6 @@ router.patch('/vote', async function (req, res) {
//
});

/**
* Get whether or not the color of a button should be colored
*/
router.patch('/getVoteColor', async function (req, res) {
//make sure user is logged in
if (req.session?.passport != null) {
//query of the user's email and the review id
const query = {
userID: req.session.passport.user.email,
reviewID: req.body['id'],
};
//get any existing vote in the db
const existingVote = (await Vote.find(query)) as VoteData[];
//result an array of either length 1 or empty
if (existingVote.length == 0) {
//if empty, both should be uncolored
res.json([false, false]);
} else {
//if not empty, there is a vote, so color it accordingly
if (existingVote[0].score == 1) {
res.json([true, false]);
} else {
res.json([false, true]);
}
}
}
});

/**
* Get multiple review colors
*/
router.patch('/getVoteColors', async function (req, res) {
if (req.session?.passport != null) {
//query of the user's email and the review id
const ids: string[] = req.body.ids;
const votes = await Vote.find({ userID: req.session.passport.user.id, reviewID: { $in: ids } });
const r: { [key: string]: number } = votes.reduce((acc: { [key: string]: number }, v) => {
acc[v.reviewID.toString()] = v.score;
return acc;
}, {});
res.json(r);
} else {
res.json({});
}
});
/*
* Verify a review
*/
Expand Down
3 changes: 1 addition & 2 deletions api/src/models/vote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ const voteSchema = new mongoose.Schema({
required: true,
},
reviewID: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Review',
type: String,
required: true,
},
timestamp: {
Expand Down
4 changes: 2 additions & 2 deletions site/src/component/Review/Review.scss
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,10 @@
color: var(--peterportal-mid-gray);
}
.coloredUpvote {
color: var(--peterportal-primary-color-1);
color: var(--peterportal-secondary-red);
}
.coloredDownvote {
color: var(--peterportal-secondary-red);
color: var(--peterportal-primary-color-1);
}
.add-review-btn {
border: none;
Expand Down
48 changes: 2 additions & 46 deletions site/src/component/Review/Review.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import './Review.scss';

import { selectReviews, setReviews, setFormStatus } from '../../store/slices/reviewSlice';
import { useAppSelector, useAppDispatch } from '../../store/hooks';
import { CourseGQLData, ProfessorGQLData, ReviewData, VoteColorsRequest, VoteColor } from '../../types/types';
import { CourseGQLData, ProfessorGQLData, ReviewData } from '../../types/types';
import { Checkbox, Dropdown } from 'semantic-ui-react';

export interface ReviewProps {
Expand All @@ -23,16 +23,10 @@ enum SortingOption {
const Review: FC<ReviewProps> = (props) => {
const dispatch = useAppDispatch();
const reviewData = useAppSelector(selectReviews);
const [voteColors, setVoteColors] = useState([]);
const [sortingOption, setSortingOption] = useState<SortingOption>(SortingOption.MOST_RECENT);
const [filterOption, setFilterOption] = useState('');
const [showOnlyVerifiedReviews, setShowOnlyVerifiedReviews] = useState(false);

const getColors = async (vote: VoteColorsRequest) => {
const res = await axios.patch('/api/reviews/getVoteColors', vote);
return res.data;
};

const getReviews = async () => {
interface paramsProps {
courseID?: string;
Expand All @@ -47,47 +41,10 @@ const Review: FC<ReviewProps> = (props) => {
})
.then(async (res: AxiosResponse<ReviewData[]>) => {
const data = res.data.filter((review) => review !== null);
const reviewIDs = [];
for (let i = 0; i < data.length; i++) {
reviewIDs.push(data[i]._id);
}
const req = {
ids: reviewIDs as string[],
};
const colors = await getColors(req);
setVoteColors(colors);
dispatch(setReviews(data));
});
};

const updateVoteColors = async () => {
const reviewIDs = [];
for (let i = 0; i < reviewData.length; i++) {
reviewIDs.push(reviewData[i]._id);
}
const req = {
ids: reviewIDs as string[],
};
const colors = await getColors(req);
setVoteColors(colors);
};

const getU = (id: string | undefined) => {
const temp = voteColors as object;
const v = temp[id as keyof typeof temp] as unknown as number;
if (v == 1) {
return {
colors: [true, false],
};
} else if (v == -1) {
return {
colors: [false, true],
};
}
return {
colors: [false, false],
};
};
useEffect(() => {
// prevent reviews from carrying over
dispatch(setReviews([]));
Expand Down Expand Up @@ -237,8 +194,7 @@ const Review: FC<ReviewProps> = (props) => {
key={review._id}
course={props.course}
professor={props.professor}
colors={getU(review._id) as VoteColor}
colorUpdater={updateVoteColors}
// updateScore={(newUserVote) => updateScore(review._id!, newUserVote)}
/>
))}
<button type="button" className="add-review-btn" onClick={openReviewForm}>
Expand Down
100 changes: 66 additions & 34 deletions site/src/component/Review/SubReview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FC, MouseEvent, useState } from 'react';
import { FC, useState } from 'react';
import axios from 'axios';
import './Review.scss';
import Badge from 'react-bootstrap/Badge';
Expand All @@ -7,67 +7,96 @@ import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import { useCookies } from 'react-cookie';
import { Link } from 'react-router-dom';
import { PersonFill } from 'react-bootstrap-icons';
import { ReviewData, VoteRequest, CourseGQLData, ProfessorGQLData, VoteColor } from '../../types/types';
import { ReviewData, VoteRequest, CourseGQLData, ProfessorGQLData } from '../../types/types';
import ReportForm from '../ReportForm/ReportForm';
import { selectReviews, setReviews } from '../../store/slices/reviewSlice';
import { useAppDispatch, useAppSelector } from '../../store/hooks';

interface SubReviewProps {
review: ReviewData;
course?: CourseGQLData;
professor?: ProfessorGQLData;
colors?: VoteColor;
colorUpdater?: () => void;
updateScore?: (newUserVote: number) => void;
}

const SubReview: FC<SubReviewProps> = ({ review, course, professor, colors, colorUpdater }) => {
const [score, setScore] = useState(review.score);
const SubReview: FC<SubReviewProps> = ({ review, course, professor }) => {
const dispatch = useAppDispatch();
const reviewData = useAppSelector(selectReviews);
const [cookies] = useCookies(['user']);
let upvoteClass;
let downvoteClass;
if (colors != undefined && colors.colors != undefined) {
upvoteClass = colors.colors[0] ? 'upvote coloredUpvote' : 'upvote';
downvoteClass = colors.colors[1] ? 'downvote coloredDownvote' : 'downvote';
} else {
upvoteClass = 'upvote';
downvoteClass = 'downvote';
}
const [reportFormOpen, setReportFormOpen] = useState<boolean>(false);
const voteReq = async (vote: VoteRequest) => {
const res = await axios.patch('/api/reviews/vote', vote);

const sendVote = async (voteReq: VoteRequest) => {
const res = await axios.patch('/api/reviews/vote', voteReq);
return res.data.deltaScore;
};

const upvote = async (e: MouseEvent) => {
const updateScore = (newUserVote: number) => {
dispatch(
setReviews(
reviewData.map((otherReview) => {
if (otherReview._id === review._id) {
return {
...otherReview,
score: otherReview.score + (newUserVote - otherReview.userVote!),
userVote: newUserVote,
};
} else {
return otherReview;
}
}),
),
);
};

const upvote = async () => {
if (cookies.user === undefined) {
alert('You must be logged in to vote.');
return;
}

const votes = {
id: ((e.target as HTMLElement).parentNode! as Element).getAttribute('id')!,
const voteReq = {
id: review._id!,
upvote: true,
};
const deltaScore = await voteReq(votes);

setScore(score + deltaScore);
if (colorUpdater != undefined) {
colorUpdater();
let newVote;
if (review.userVote === 1) {
newVote = 0;
} else if (review.userVote === 0) {
newVote = 1;
} else {
newVote = 1;
}
updateScore(newVote);
await sendVote(voteReq).catch((err) => {
console.error('Error sending upvote:', err);
updateScore(review.userVote!);
});
};

const downvote = async (e: MouseEvent) => {
const downvote = async () => {
if (cookies.user === undefined) {
alert('You must be logged in to vote.');
return;
}
const votes = {
id: ((e.target as HTMLElement).parentNode! as Element).getAttribute('id')!,
const voteReq = {
id: review._id!,
upvote: false,
};
const deltaScore = await voteReq(votes);
setScore(score + deltaScore);
if (colorUpdater != undefined) {
colorUpdater();

let newVote;
if (review.userVote === 1) {
newVote = -1;
} else if (review.userVote === 0) {
newVote = -1;
} else {
newVote = 0;
}
updateScore(newVote);
await sendVote(voteReq).catch((err) => {
console.error('Error sending downvote:', err);
updateScore(review.userVote!);
});
};

const openReportForm = () => {
Expand All @@ -77,6 +106,9 @@ const SubReview: FC<SubReviewProps> = ({ review, course, professor, colors, colo
const badgeOverlay = <Tooltip id="verified-tooltip">This review was verified by an administrator.</Tooltip>;
const authorOverlay = <Tooltip id="authored-tooltip">You are the author of this review.</Tooltip>;

const upvoteClassname = review.userVote === 1 ? 'upvote coloredUpvote' : 'upvote';
const downvoteClassname = review.userVote === -1 ? 'downvote coloredDownvote' : 'downvote';

const verifiedBadge = (
<OverlayTrigger overlay={badgeOverlay}>
<Badge variant="primary">Verified</Badge>
Expand Down Expand Up @@ -171,11 +203,11 @@ const SubReview: FC<SubReviewProps> = ({ review, course, professor, colors, colo
</div>
<div className="subreview-footer" id={review._id}>
<p>Helpful?</p>
<button className={upvoteClass} onClick={upvote}>
<button className={upvoteClassname} onClick={upvote}>
&#9650;
</button>
<p>{score}</p>
<button className={downvoteClass} onClick={downvote}>
<p>{review.score}</p>
<button className={downvoteClassname} onClick={downvote}>
&#9660;
</button>
<button type="button" className="add-report-button" onClick={openReportForm}>
Expand Down
Loading

0 comments on commit e800308

Please sign in to comment.