From 4c846a0c3584c2d0a45a4999945660d1c134e64d Mon Sep 17 00:00:00 2001 From: Omar Date: Mon, 7 Oct 2024 12:43:18 -0400 Subject: [PATCH] Updated Comments & Solutions Page (#97) * updated solutions page * Nested comments can now be deleted + bugfix --------- Co-authored-by: Eren --- client/src/components/Comment.tsx | 315 +++++++++++++----------- client/src/components/CommentForm.tsx | 42 ++++ client/src/pages/Solution.tsx | 200 +++++++++------ client/src/types/CommentData.ts | 2 +- server/controllers/CommentController.js | 46 +++- server/routes/CommentRoutes.js | 9 +- 6 files changed, 396 insertions(+), 218 deletions(-) create mode 100644 client/src/components/CommentForm.tsx diff --git a/client/src/components/Comment.tsx b/client/src/components/Comment.tsx index b40d6d7..a0eecb8 100644 --- a/client/src/components/Comment.tsx +++ b/client/src/components/Comment.tsx @@ -1,182 +1,217 @@ -import Card from "react-bootstrap/Card"; -import Button from "./Button.tsx"; -import { CommentData } from "../types/CommentData.ts"; -import { Form } from "react-bootstrap"; -import { useEffect, useState } from "react"; -import { CaretUpFill, CaretUp } from "react-bootstrap-icons"; +import React, { useState, useEffect } from "react"; +import { Card, Button, Form, Dropdown, Modal } from "react-bootstrap"; import { useRemark } from "react-remark"; +import { CaretUpFill, CaretUp } from "react-bootstrap-icons"; import dayjs from "dayjs"; +import { CommentData } from "../types/CommentData"; +import CommentForm from "./CommentForm"; -type CommentProps = NonEditableCommentProps | EditableCommentProps; - -type NonEditableCommentProps = { +type CommentProps = { comment: CommentData; onSubmit: (parentId: number, text: string) => void; - editable: false; + onDelete: (commentId: number) => void; + onEdit: (commentId: number, newText: string) => void; depth: number; + currentUserId: string; + isAdmin: boolean; }; -type EditableCommentProps = { - parentId?: number; - onSubmit: (parentId: number, text: string) => void; - editable: true; -}; - -type UpvoterProps = { - commentId: number; - upVotes: number; - hasUpvoted: boolean; -}; - -const Upvoter = ({ commentId, upVotes, hasUpvoted }: UpvoterProps) => { - const [upVotesState, setUpVotesState] = useState(upVotes); - const [hasUpvotedState, setHasUpvotedState] = useState(hasUpvoted); - return ( - - ); -}; - -const NonEditableComment = ({ +const Comment = ({ comment, onSubmit, + onDelete, + onEdit, depth, -}: NonEditableCommentProps) => { + currentUserId, + isAdmin, +}: CommentProps) => { const [isReplying, setIsReplying] = useState(false); + const [isEditing, setIsEditing] = useState(false); const [repliesOpen, setRepliesOpen] = useState(false); - const repliesAvailable = comment.replies.length !== 0; + const [newText, setNewText] = useState(comment.text); const [renderedMarkdown, setMarkdownSource] = useRemark(); + const [showDeleteModal, setShowDeleteModal] = useState(false); useEffect(() => { setMarkdownSource(comment.text); }, [comment.text]); - console.log(comment); + const handleReply = (_: never, text: string) => { + if (typeof text !== "string" || !text.trim()) return; + onSubmit(comment.id, text); + setIsReplying(false); + }; + + const handleEdit = () => { + if (typeof newText !== "string" || !newText.trim()) return; + onEdit(comment.id, newText); + setIsEditing(false); + }; + + const handleDelete = () => { + onDelete(comment.id); + setShowDeleteModal(false); + }; + + const handleUpvote = () => { + fetch(`/api/comments/upvote/${comment.id}`, { + method: "POST", + }) + .then((response) => { + if (!response.ok) { + throw new Error(`Failed to upvote comment: ${response.statusText}`); + } + // Optionally update UI optimistically or refetch comments + }) + .catch((err) => { + console.error(err); + }); + }; + + const handleShowDeleteModal = () => { + setShowDeleteModal(true); + }; + const handleCloseDeleteModal = () => { + setShowDeleteModal(false); + }; return ( <>
- {comment.userId} {/* Displaying username */} - {dayjs(comment.createdAt).format("MMM D, YYYY")}{" "} - {/* Formatting and displaying date */} -
-
- - {renderedMarkdown} -
- - {depth === 0 && ( - <> - - {repliesAvailable && ( - - )} - + Edit + + + Delete + + + + )} +
+ + + {isEditing ? ( +
{ + e.preventDefault(); + handleEdit(); + }} + > + + { + setNewText(e.target.value); + }} + /> + + +
+ ) : ( + {renderedMarkdown} + )} +
+ + {depth === 0 && ( + + )} + {depth === 0 && comment.replies.length > 0 && ( + )}
- - {depth === 0 && ( -
- {isReplying && ( + {isReplying && ( +
+ +
+ )} + {repliesOpen && ( +
+ {comment.replies.map((reply) => ( - )} - {repliesAvailable && - repliesOpen && - comment.replies.map((c) => ( - - ))} + ))}
)} - - ); -}; - -const EditableComment = ({ onSubmit, parentId }: EditableCommentProps) => { - const [newComment, setNewComment] = useState(""); - return ( - - -
{ - e.preventDefault(); - setNewComment(""); - onSubmit(parentId, newComment); - }} - > - - { - setNewComment(e.target.value); - }} - /> - - -
-
-
- ); -}; - -const Comment = ({ editable, ...otherProps }: CommentProps) => { - return editable ? ( - - ) : ( - + + + + ); }; diff --git a/client/src/components/CommentForm.tsx b/client/src/components/CommentForm.tsx new file mode 100644 index 0000000..69615ac --- /dev/null +++ b/client/src/components/CommentForm.tsx @@ -0,0 +1,42 @@ +import React, { useState } from "react"; +import { Card, Form, Button } from "react-bootstrap"; + +type CommentFormProps = { + parentId?: number; + onSubmit: (parentId: number | undefined, text: string) => void; +}; + +const CommentForm = ({ parentId, onSubmit }: CommentFormProps) => { + const [text, setText] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (typeof text !== "string" || !text.trim()) return; + onSubmit(parentId, text); + setText(""); + }; + + return ( + + +
+ + { + setText(e.target.value); + }} + /> + + +
+
+
+ ); +}; + +export default CommentForm; diff --git a/client/src/pages/Solution.tsx b/client/src/pages/Solution.tsx index 77f3fbc..dc16750 100644 --- a/client/src/pages/Solution.tsx +++ b/client/src/pages/Solution.tsx @@ -1,11 +1,19 @@ import React, { useState, useEffect } from "react"; -import { Container, Row, Col, Card } from "react-bootstrap"; +import { + Container, + Row, + Col, + Card, + Modal, + Button as BootstrapButton, + Dropdown, +} from "react-bootstrap"; import { useParams } from "react-router-dom"; -import { SolutionData } from "../types/SolutionData.ts"; -import { CommentData } from "../types/CommentData.ts"; -import Button from "../components/Button.tsx"; -import Comment from "../components/Comment.tsx"; -import useCheckRole from "../hooks/useCheckRole.tsx"; // Make sure the path is correct +import { SolutionData } from "../types/SolutionData"; +import { CommentData } from "../types/CommentData"; +import Comment from "../components/Comment"; +import CommentForm from "../components/CommentForm"; +import useCheckRole from "../hooks/useCheckRole"; // Make sure the path is correct import dayjs from "dayjs"; function loadSolution(id, setter, setForbidden) { @@ -43,6 +51,7 @@ const Solution = () => { const { isAdmin, isLoading } = useCheckRole(); const [currentUserId, setCurrentUserId] = useState(null); const [forbidden, setForbidden] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); useEffect(() => { if (id) { @@ -51,6 +60,8 @@ const Solution = () => { } }, [id]); + console.log(comments); + useEffect(() => { fetch("/api/users/whoami") .then((response) => response.json()) @@ -73,15 +84,7 @@ const Solution = () => { }); }; - if (isLoading) { - return
Loading...
; - } - if (forbidden) { - return
Access denied
; - } - - const handleDelete = (commentId) => { - if (!isAdmin) return; + const handleDeleteComment = (commentId) => { fetch(`/api/comments/${commentId}`, { method: "DELETE" }) .then(() => { setComments((comments) => @@ -93,10 +96,24 @@ const Solution = () => { }); }; - const handleSubmit = (parentId, text) => { + const handleEditComment = (commentId, newText) => { + fetch(`/api/comments/${commentId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: newText }), + }) + .then(() => { + loadComments(id, setComments); + }) + .catch((err) => { + console.error("Failed to edit comment", err); + }); + }; + + const handleSubmitComment = (parentId, text) => { const endpoint = parentId ? `/api/comments/reply/${parentId}` - : `/api/comments/${solutionData.id}`; + : `/api/comments/`; fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -110,73 +127,114 @@ const Solution = () => { }); }; + const handleCloseDeleteModal = () => { + setShowDeleteModal(false); + }; + const handleShowDeleteModal = () => { + setShowDeleteModal(true); + }; + + if (isLoading) { + return
Loading...
; + } + if (forbidden) { + return
Access denied
; + } + return ( - -

Solution

+ {solutionData && ( - - -
- By: {solutionData.userId} - - {dayjs(solutionData.createdAt).format("MMMM D, YYYY")} - -
-
- - {solutionData.title} - {solutionData.description} - - {solutionData.diagram && ( - - )} - - {isAdmin || solutionData.userId === currentUserId ? ( - - - - ) : null} -
+ <> +

+ {solutionData.challengeTitle} - Solution #{solutionData.id} +

+ + +
+
+ {solutionData.userId} + + {dayjs(solutionData.createdAt).format("MMMM D, YYYY")} + +
+ {(isAdmin || solutionData.userId === currentUserId) && ( + + + + + + + Delete + + + + )} +
+
+ + {solutionData.title} + {solutionData.description} + {solutionData.diagram && ( + + )} + +
+ )}
- -

Comments

- - {comments && + +

Student's Answers

+ {Array.isArray(comments) && comments.map((comment) => ( -
- - {isAdmin && ( - - )} -
+ { + handleSubmitComment(parentId, text); + }} + onDelete={handleDeleteComment} + onEdit={handleEditComment} + depth={0} + currentUserId={currentUserId} + isAdmin={isAdmin} + /> ))} + { + handleSubmitComment(parentId, text); + }} + />
+ + + + Confirm Deletion + + Are you sure you want to delete this solution? + + + Cancel + + + Delete + + +
); }; -export default Solution; \ No newline at end of file +export default Solution; diff --git a/client/src/types/CommentData.ts b/client/src/types/CommentData.ts index 1bc2dac..7770ed0 100644 --- a/client/src/types/CommentData.ts +++ b/client/src/types/CommentData.ts @@ -1,7 +1,7 @@ export type CommentData = { id: number; solutionId: number; - userId: number; + userId: string; text: string; upVotes: number; hasUserUpvoted: boolean; diff --git a/server/controllers/CommentController.js b/server/controllers/CommentController.js index 01b115f..4fea05f 100644 --- a/server/controllers/CommentController.js +++ b/server/controllers/CommentController.js @@ -4,6 +4,7 @@ const Challenge = db.Challenge; const Solution = db.Solution; const User = db.User; const { AITA } = require("../AI/AITA"); +const { Op } = require("sequelize"); function formatComments(extracted) { const convertedReplies = extracted.map((c) => ({ @@ -118,7 +119,50 @@ exports.edit = async (req, res) => { exports.delete = async (req, res) => { const { id } = req.params; - await Comment.destroy({ where: { id } }); + const { user } = req; + + if (!id || !user) { + res.status(400).send(); + return; + } + + const comment = await db.Comment.findOne({ + where: { id }, + }); + + if (!comment || !user) { + res.status(402).send(); + return; + } + + const parents = await db.Comment.findAll({ + where: { + replies: { + [Op.substring]: `${comment.id}`, + }, + }, + }); + + if (user.role === "admin" || user.username === comment.userId) { + await Comment.destroy({ where: { id } }); + await Promise.all( + parents.map((c) => { + return Comment.update( + { + replies: c.replies + .split(",") + .filter((x) => x !== `${id}`) + .join(","), + }, + { where: { id: c.id } }, + ); + }), + ); + } else { + res.status(403).send(); + return; + } + res.status(204).send(); }; diff --git a/server/routes/CommentRoutes.js b/server/routes/CommentRoutes.js index d9a47cd..33c41e7 100644 --- a/server/routes/CommentRoutes.js +++ b/server/routes/CommentRoutes.js @@ -15,19 +15,18 @@ router.get("/user/:username", Comment.getUserComments); router.get("/:solutionId", Comment.get); // Create a new comment for a solution in the database. -router.post("/:id", Comment.create); +router.post("/", Comment.create); // Edit a comment for a solution in the database. -router.put("/:id", Comment.edit); +router.put("/:id", Comment.edit); // Delete a comment for a solution from the database. -router.delete("/:id", checkRole(['admin']), Comment.delete); - +router.delete("/:id", Comment.delete); // Reply to a comment router.post("/reply/:parentId", Comment.reply); // Upvote a comment -router.get("/upvote/:commentId", Comment.upvote); +router.post("/upvote/:commentId", Comment.upvote); module.exports = router;