diff --git a/api/src/controllers/reviews.ts b/api/src/controllers/reviews.ts index 467daad6..737b5f30 100644 --- a/api/src/controllers/reviews.ts +++ b/api/src/controllers/reviews.ts @@ -10,6 +10,14 @@ import Vote from '../models/vote'; import Report from '../models/report'; const router = express.Router(); +async function userWroteReview(userID: string | undefined, reviewID: string) { + if (!userID) { + return false; + } + + return await Review.exists({ _id: reviewID, userID: userID }); +} + /** * Get review scores */ @@ -68,7 +76,7 @@ router.get('/featured', async function (req: Request { if (reviewsCollection) { @@ -200,12 +208,6 @@ router.post('/', async function (req, res) { // Set on server so the client can't automatically verify their own review. req.body.verified = verifiedCount >= 3; // auto-verify if use has posted 3+ reviews - // Verify the captcha - const verifyResponse = await verifyCaptcha(req.body); - if (!verifyResponse?.success) - return res.status(400).json({ error: 'ReCAPTCHA token is invalid', data: verifyResponse }); - delete req.body.captchaToken; // so it doesn't get stored in DB - //check if review already exists for same professor, course, and user const query: ReviewFilter = { courseID: req.body.courseID, @@ -216,14 +218,18 @@ router.post('/', async function (req, res) { const reviews = await Review.find(query); if (reviews?.length > 0) return res.status(400).json({ error: 'Review already exists for this professor and course!' }); + + // Verify the captcha + const verifyResponse = await verifyCaptcha(req.body); + if (!verifyResponse?.success) + return res.status(400).json({ error: 'ReCAPTCHA token is invalid', data: verifyResponse }); + delete req.body.captchaToken; // so it doesn't get stored in DB + // add review to mongo req.body.userDisplay = req.body.userDisplay === 'Anonymous Peter' ? 'Anonymous Peter' : req.session.passport.user.name; req.body.userID = req.session.passport.user.id; - await new Review(req.body).save(); - - // echo back body - res.json(req.body); + res.json(await new Review(req.body).save()); } catch { res.json({ error: 'Cannot add review' }); } @@ -237,11 +243,7 @@ router.post('/', async function (req, res) { */ router.delete('/', async (req, res) => { try { - const checkUser = async () => { - return await Review.findOne({ _id: req.body.id as string, userID: req.session.passport?.user.id }).exec(); - }; - - if (req.session.passport?.admin || (await checkUser())) { + if (req.session.passport?.admin || (await userWroteReview(req.session.passport?.user.id, req.body.id))) { await Review.deleteOne({ _id: req.body.id }); await Vote.deleteMany({ reviewID: req.body.id }); await Report.deleteMany({ reviewID: req.body.id }); @@ -332,5 +334,23 @@ router.delete('/clear', async function (req, res) { res.json({ error: 'Can only clear on development environment' }); } }); +/** + * Updating the review + */ +router.patch('/update', async function (req, res) { + if (req.session.passport) { + if (!(await userWroteReview(req.session.passport.user.id, req.body._id))) { + return res.json({ error: 'You are not the author of this review.' }); + } + + const updatedReviewBody = req.body; + + const { _id, ...updateWithoutId } = updatedReviewBody; + await Review.updateOne({ _id }, updateWithoutId); + res.json(updatedReviewBody); + } else { + res.status(401).json({ error: 'Must be logged in to update a review.' }); + } +}); export default router; diff --git a/site/src/component/Review/Review.scss b/site/src/component/Review/Review.scss index 33ab6220..5399a293 100644 --- a/site/src/component/Review/Review.scss +++ b/site/src/component/Review/Review.scss @@ -3,6 +3,8 @@ border-radius: var(--border-radius); padding: 2rem; margin-bottom: 3vh; + word-wrap: break-word; + overflow-wrap: break-word; } // name of the class/professor .subreview-identifier { @@ -12,6 +14,7 @@ a { color: var(--text); } + word-break: break-word; } // content of review .subreview-content { @@ -59,6 +62,8 @@ } .subreview-info { width: 100%; + flex: 1; + min-width: 0; } .subreview-details { margin: 1vh 0; @@ -212,3 +217,10 @@ } } } + +.edit-buttons { + display: flex; + flex-direction: row; + float: right; + gap: 10px; +} diff --git a/site/src/component/Review/Review.tsx b/site/src/component/Review/Review.tsx index 71a10354..bd0a77aa 100644 --- a/site/src/component/Review/Review.tsx +++ b/site/src/component/Review/Review.tsx @@ -1,4 +1,4 @@ -import { FC, useState, useEffect } from 'react'; +import { FC, useState, useEffect, useCallback } from 'react'; import axios, { AxiosResponse } from 'axios'; import SubReview from './SubReview'; import ReviewForm from '../ReviewForm/ReviewForm'; @@ -26,8 +26,9 @@ const Review: FC = (props) => { const [sortingOption, setSortingOption] = useState(SortingOption.MOST_RECENT); const [filterOption, setFilterOption] = useState(''); const [showOnlyVerifiedReviews, setShowOnlyVerifiedReviews] = useState(false); + const showForm = useAppSelector((state) => state.review.formOpen); - const getReviews = async () => { + const getReviews = useCallback(async () => { interface paramsProps { courseID?: string; professorID?: string; @@ -43,13 +44,13 @@ const Review: FC = (props) => { const data = res.data.filter((review) => review !== null); dispatch(setReviews(data)); }); - }; + }, [dispatch, props.course, props.professor]); useEffect(() => { // prevent reviews from carrying over dispatch(setReviews([])); getReviews(); - }, [props.course?.id, props.professor?.ucinetid]); + }, [dispatch, getReviews]); let sortedReviews: ReviewData[]; // filter verified if option is set @@ -189,19 +190,13 @@ const Review: FC = (props) => { {sortedReviews.map((review) => ( - updateScore(review._id!, newUserVote)} - /> + ))} - + ); } diff --git a/site/src/component/Review/SubReview.tsx b/site/src/component/Review/SubReview.tsx index c5b88e8d..aac27ab2 100644 --- a/site/src/component/Review/SubReview.tsx +++ b/site/src/component/Review/SubReview.tsx @@ -1,4 +1,4 @@ -import { FC, useState } from 'react'; +import { FC, useContext, useState } from 'react'; import axios from 'axios'; import './Review.scss'; import Badge from 'react-bootstrap/Badge'; @@ -6,17 +6,19 @@ import Tooltip from 'react-bootstrap/Tooltip'; import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; import { useCookies } from 'react-cookie'; import { Link } from 'react-router-dom'; -import { PersonFill } from 'react-bootstrap-icons'; +import { PencilFill, PersonFill, TrashFill } from 'react-bootstrap-icons'; 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'; +import { Button, Modal } from 'react-bootstrap'; +import ThemeContext from '../../style/theme-context'; +import ReviewForm from '../ReviewForm/ReviewForm'; interface SubReviewProps { review: ReviewData; course?: CourseGQLData; professor?: ProfessorGQLData; - updateScore?: (newUserVote: number) => void; } const SubReview: FC = ({ review, course, professor }) => { @@ -24,6 +26,10 @@ const SubReview: FC = ({ review, course, professor }) => { const reviewData = useAppSelector(selectReviews); const [cookies] = useCookies(['user']); const [reportFormOpen, setReportFormOpen] = useState(false); + const { darkMode } = useContext(ThemeContext); + const buttonVariant = darkMode ? 'dark' : 'secondary'; + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showReviewForm, setShowReviewForm] = useState(false); const sendVote = async (voteReq: VoteRequest) => { const res = await axios.patch('/api/reviews/vote', voteReq); @@ -48,6 +54,12 @@ const SubReview: FC = ({ review, course, professor }) => { ); }; + const deleteReview = async (reviewID: string) => { + await axios.delete('/api/reviews', { data: { id: reviewID } }); + dispatch(setReviews(reviewData.filter((review) => review._id !== reviewID))); + setShowDeleteModal(false); + }; + const upvote = async () => { if (cookies.user === undefined) { alert('You must be logged in to vote.'); @@ -103,6 +115,16 @@ const SubReview: FC = ({ review, course, professor }) => { setReportFormOpen(true); }; + const openReviewForm = () => { + setShowReviewForm(true); + document.body.style.overflow = 'hidden'; + }; + + const closeReviewForm = () => { + setShowReviewForm(false); + document.body.style.overflow = 'visible'; + }; + const badgeOverlay = This review was verified by an administrator.; const authorOverlay = You are the author of this review.; @@ -117,12 +139,38 @@ const SubReview: FC = ({ review, course, professor }) => { const authorBadge = ( - + + + ); return (
+ {cookies.user?.id === review.userID && ( +
+ + + setShowDeleteModal(false)} centered> + +

Delete Review

+
+ Deleting a review will remove it permanently. Are you sure you want to proceed? + + + + +
+
+ )}

{professor && ( @@ -137,7 +185,8 @@ const SubReview: FC = ({ review, course, professor }) => { )} {!course && !professor && (
- {review.courseID} {review.professorID} + {review.courseID}{' '} + {review.professorID}
)}

@@ -219,6 +268,14 @@ const SubReview: FC = ({ review, course, professor }) => { reviewContent={review.reviewContent} closeForm={() => setReportFormOpen(false)} /> +
); diff --git a/site/src/component/ReviewForm/ReviewForm.scss b/site/src/component/ReviewForm/ReviewForm.scss index eb7136b7..613d1914 100644 --- a/site/src/component/ReviewForm/ReviewForm.scss +++ b/site/src/component/ReviewForm/ReviewForm.scss @@ -64,9 +64,6 @@ cursor: pointer; } } -.review-form-grade { - margin-left: 1vw; -} .review-form-section { background-color: var(--overlay3); border-radius: var(--border-radius); @@ -74,6 +71,8 @@ } .review-form-row { display: flex; + flex-wrap: wrap; + column-gap: 16px; } .review-form-center { display: flex; @@ -100,7 +99,7 @@ margin: auto auto 25px auto; cursor: pointer; } -.review-form-submit { +.review-form-captcha-submit { display: flex; justify-content: space-between; align-items: center; @@ -124,6 +123,13 @@ top: 2vh; } +.review-form-submit-cancel-buttons { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 14px; +} + @media only screen and (max-width: 800px) { .review-form { height: 80vh; @@ -134,22 +140,4 @@ .review-form-taken { margin-bottom: 3vh; } - - .review-form-submit { - flex-direction: column; - - button { - margin-top: 1vh; - } - } -} - -@media only screen and (max-width: 1700px) { - .review-form-row { - flex-direction: column; - } - - .review-form-grade { - margin-left: 0; - } } diff --git a/site/src/component/ReviewForm/ReviewForm.tsx b/site/src/component/ReviewForm/ReviewForm.tsx index 3c04270f..21c5d832 100644 --- a/site/src/component/ReviewForm/ReviewForm.tsx +++ b/site/src/component/ReviewForm/ReviewForm.tsx @@ -11,9 +11,8 @@ import Button from 'react-bootstrap/Button'; import RangeSlider from 'react-bootstrap-range-slider'; import Modal from 'react-bootstrap/Modal'; import ReCAPTCHA from 'react-google-recaptcha'; - -import { addReview } from '../../store/slices/reviewSlice'; -import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { addReview, editReview } from '../../store/slices/reviewSlice'; +import { useAppDispatch } from '../../store/hooks'; import { ReviewProps } from '../Review/Review'; import { ReviewData } from '../../types/types'; import ThemeContext from '../../style/theme-context'; @@ -21,9 +20,19 @@ import { quarterNames } from '../../helpers/planner'; interface ReviewFormProps extends ReviewProps { closeForm: () => void; + show: boolean; + editing?: boolean; + reviewToEdit?: ReviewData; } -const ReviewForm: FC = (props) => { +const ReviewForm: FC = ({ + closeForm, + show, + editing, + reviewToEdit, + professor: professorProp, + course: courseProp, +}) => { const dispatch = useAppDispatch(); const grades = ['A+', 'A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D+', 'D', 'D-', 'F', 'P', 'NP']; const tags = [ @@ -44,57 +53,62 @@ const ReviewForm: FC = (props) => { 'Group projects', 'Gives good feedback', ]; - - const [professor, setProfessor] = useState(props.professor?.ucinetid || ''); - const [course, setCourse] = useState(props.course?.id || ''); - const [yearTaken, setYearTaken] = useState(''); - const [quarterTaken, setQuarterTaken] = useState(''); - const [gradeReceived, setGradeReceived] = useState(''); - const [userID, setUserID] = useState(''); - const [userName, setUserName] = useState('Anonymous Peter'); - const [content, setContent] = useState(''); - const [quality, setQuality] = useState(3); - const [difficulty, setDifficulty] = useState(3); - const [takeAgain, setTakeAgain] = useState(false); - const [textbook, setTextbook] = useState(false); - const [attendance, setAttendance] = useState(false); - const [selectedTags, setSelectedTags] = useState([]); + const [professor, setProfessor] = useState(professorProp?.ucinetid ?? reviewToEdit?.professorID ?? ''); + const [course, setCourse] = useState(courseProp?.id ?? reviewToEdit?.courseID ?? ''); + const [yearTakenDefault, quarterTakenDefault] = reviewToEdit?.quarter.split(' ') ?? ['', '']; + const [yearTaken, setYearTaken] = useState(yearTakenDefault); + const [quarterTaken, setQuarterTaken] = useState(quarterTakenDefault); + const [gradeReceived, setGradeReceived] = useState(reviewToEdit?.gradeReceived ?? ''); + const [content, setContent] = useState(reviewToEdit?.reviewContent ?? ''); + const [quality, setQuality] = useState(reviewToEdit?.rating ?? 3); + const [difficulty, setDifficulty] = useState(reviewToEdit?.difficulty ?? 3); + const [takeAgain, setTakeAgain] = useState(reviewToEdit?.takeAgain ?? false); + const [textbook, setTextbook] = useState(reviewToEdit?.textbook ?? false); + const [attendance, setAttendance] = useState(reviewToEdit?.attendance ?? false); + const [selectedTags, setSelectedTags] = useState(reviewToEdit?.tags ?? []); const [captchaToken, setCaptchaToken] = useState(''); const [submitted, setSubmitted] = useState(false); - const [overCharLimit, setOverCharLimit] = useState(false); const [cookies] = useCookies(['user']); + const userID: string = cookies.user?.id; + const [userName, setUserName] = useState(reviewToEdit?.userDisplay ?? cookies.user?.name); const [validated, setValidated] = useState(false); - const showForm = useAppSelector((state) => state.review.formOpen); const { darkMode } = useContext(ThemeContext); useEffect(() => { - // get user info from cookie - if (cookies.user) { - setUserID(cookies.user.id); - setUserName(cookies.user.name); - } - }, [cookies]); - - useEffect(() => { - // upon opening this form - if (showForm) { + if (show) { + // form opened // if not logged in, close the form if (cookies.user === undefined) { alert('You must be logged in to add a review!'); - props.closeForm(); + closeForm(); } + + setValidated(false); + setSubmitted(false); } - }, [showForm, props, cookies]); + // we do not want closeForm to be a dependency, would cause unexpected behavior since the closeForm function is different on each render + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [show]); const postReview = async (review: ReviewData) => { - const res = await axios.post('/api/reviews', review).catch((err) => err.response); - if (res.status === 400) { - alert(res.data.error ?? 'You have already submitted a review for this course/professor'); - } else if (res.data.error !== undefined) { - alert('Error submitting review'); + if (editing) { + const res = await axios.patch('/api/reviews/update', review); + if (res.data.error !== undefined) { + alert(res.data.error); + } else { + setSubmitted(true); + dispatch(editReview(res.data)); + } } else { - setSubmitted(true); - dispatch(addReview(res.data)); + const res = await axios.post('/api/reviews', review).catch((err) => err.response); + if (res.status === 400) { + alert(res.data.error ?? 'You have already submitted a review for this course/professor'); + } else if (res.data.error !== undefined) { + alert('You must be logged in to add a review!'); + } else { + setSubmitted(true); + dispatch(addReview(res.data)); + } } }; @@ -109,43 +123,38 @@ const ReviewForm: FC = (props) => { setValidated(true); // do not proceed if not valid - if (valid === false) { + if (!valid) { return; } - if (!captchaToken) { + // check if CAPTCHA is completed for new reviews (captcha omitted for editing) + if (!editing && !captchaToken) { alert('Please complete the CAPTCHA'); return; } - - const date = new Date(); - const year = date.getFullYear(); - const month = (1 + date.getMonth()).toString(); - const day = date.getDate().toString(); + const timestamp = new Date().toLocaleDateString('en-US'); const review = { + _id: reviewToEdit?._id, professorID: professor, courseID: course, - userID: userID, + userID, userDisplay: userName, reviewContent: content, rating: quality, - difficulty: difficulty, - timestamp: month + '/' + day + '/' + year, - gradeReceived: gradeReceived, + difficulty, + timestamp, + gradeReceived, forCredit: true, quarter: yearTaken + ' ' + quarterTaken, score: 0, - takeAgain: takeAgain, - textbook: textbook, - attendance: attendance, + takeAgain, + textbook, + attendance, tags: selectedTags, - captchaToken: captchaToken, + verified: false, + captchaToken, }; - if (content.length > 500) { - setOverCharLimit(true); - } else { - setOverCharLimit(false); - postReview(review); - } + + postReview(review); }; const selectTag = (tag: string) => { @@ -168,7 +177,7 @@ const ReviewForm: FC = (props) => { }; // select instructor if in course context - const instructorSelect = props.course && ( + const instructorSelect = courseProp && ( Taken With = (props) => { defaultValue="" required onChange={(e) => setProfessor(e.target.value)} + value={professor} > - {Object.keys(props.course?.instructors).map((ucinetid) => { - const name = props.course?.instructors[ucinetid].shortenedName; + {Object.keys(courseProp?.instructors).map((ucinetid) => { + const name = courseProp?.instructors[ucinetid].shortenedName; return ( ); // select course if in professor context - const courseSelect = props.professor && ( + const courseSelect = professorProp && ( Course Taken = (props) => { defaultValue="" required onChange={(e) => setCourse(e.target.value)} + value={course} > - {Object.keys(props.professor?.courses).map((courseID) => { + {Object.keys(professorProp?.courses).map((courseID) => { const name = - props.professor?.courses[courseID].department + ' ' + props.professor?.courses[courseID].courseNumber; + professorProp?.courses[courseID].department + ' ' + professorProp?.courses[courseID].courseNumber; return ( ); + function editReviewHeading() { + if (!courseProp && !professorProp) { + return `Edit your review for ${reviewToEdit?.courseID} ${reviewToEdit?.professorID}`; + } else if (courseProp) { + return `Edit your review for ${courseProp?.department} ${courseProp?.courseNumber}`; + } else { + return `Edit your review for ${professorProp?.name}`; + } + } + const reviewForm = (
-

- It's your turn to review{' '} - {props.course ? props.course?.department + ' ' + props.course?.courseNumber : props.professor?.name} -

+ {editing ? ( +

{editReviewHeading()}

+ ) : ( +

+ It's your turn to review{' '} + {courseProp ? courseProp?.department + ' ' + courseProp?.courseNumber : professorProp?.name} +

+ )}
@@ -245,7 +270,7 @@ const ReviewForm: FC = (props) => {
{instructorSelect} {courseSelect} - + Grade = (props) => { defaultValue="" required onChange={(e) => setGradeReceived(e.target.value)} + value={gradeReceived} >