Building Dynamic Conversations: A Step-by-Step Guide to Implementing a Nested Comment System in Your Node.js Application
This blog post is about adding nested comment system in nodejs application. I have built a blogging web application PBlog using Express.js and handlebar where user can create an account, login to the system and can write a post using markdown. Now, I am adding a nested comment system to this application. A nested comment system is a feature that allows users to comment on a post add replies to other comments and create a hierarchy of comments.
Now, let's create a file comment.js
inside Model
folder. In comment.js
file we will define a schema for comments.
Comment schema
const {Schema,model} = require("mongoose");
const commentSchema = new Schema({
postedBy:{
type: Schema.Types.ObjectId,
required:true,
ref:"Users"
},
postId:{
type:Schema.Types.ObjectId,
required:true,
ref:"posts"
},
text:{
type:String,
required:true
},
parentComment:{
type:Schema.Types.ObjectId,
ref:"comments",
default:null
},
commentedAt:{
type:Date,
default: Date.now()
},
replies:[{
type: Schema.Types.ObjectId,
ref: "comments"
}]
});
commentSchema.pre("find", function( next){
this.populate({path:"replies",
populate:{path:"postedBy"}
})
next()
})
const Comment = new model("comments", commentSchema);
module.exports = Comment;
In the above code:
- postedBy: A reference to a user who posted that comment or reply
- text: A required field representing the
text
content of the comment. - postId: A reference to a Post using its ObjectId.
- parentComment: A reference to another comment (if it's a reply), defaulting to null for top-level comments.
- commentedAt: The timestamp for when the comment was created, with a default value of the current date and time.
- replies: An array of ObjectId references to other comments, forming a nested structure for comment replies.
Following code will pre-Populate Replies and user information for each reply:
commentSchema.pre("find", function( next){
this.populate({path:"replies",
populate:{path:"postedBy"}
})
next()
})
It is a Mongoose middleware using pre
which runs before the find
operation. It populates the replies
field with actual comment objects, and user information for each reply when querying the database.
Finally, we create and export the Comment
model to make it available for use in other parts of the application
Now, let's create a route to handle the submission of comment for a specific post. I have created a new file Comment.js
in route
folder. This file will handle all routes related to comments.
Route to post a comment
const commentRoute = require("express").Router()
const flash = require("connect-flash");
const {body, validationResult} = require("express-validator")
const {islogin} = require("../utils/loginHandeler")
const Comment = require("../module/comment");
commentRoute.post("/:postId",islogin,[body("comment").trim().escape().notEmpty().withMessage("Comment field can not be empty!")], async(req,res)=>{
const errors = validationResult(req)
const {postId} = req.params;
if(errors.isEmpty()){
const {comment} = req.body;
let commentObj = {
postedBy:req.session.uid,
postId,
text:comment,
}
try{
await new Comment(commentObj).save()
}catch(er){
console.log(er.message)
}
}else{
req.flash("error", `Failed to post a comment`)
}
res.redirect(`/${postId}#comment-field`)
})
Here, we've:
- Defined a route for handling comments
- imported
connect-flash
for flash messages,express-validator
for input validation,islogin
is a middleware for checking user is logged in or not. Because user need to log in to post a comment, andComment
model - set up
Post
route to handle comment submissions - used
express-validator
to validate the comment input, ensuring it's not empty and escaping any potentially harmful characters - Saved comments in the database if there are no errors. If there is a validation error we've flashed an error message to be displayed Finally, the user is redirected to the same post.
Route to post a reply in a particular comment
commentRoute.post("/:commentId/reply/:postId", islogin,[body("replyText").trim().escape().notEmpty()], async(req,res)=>{
const {commentId, postId} = req.params;
const errors = validationResult(req)
const {replyText} = req.body;
if(errors.isEmpty()){
const replyObj = {
postedBy:req.session.uid,
postId,
text:replyText,
parentComment:commentId
}
try{
const newReply = await new Comment(replyObj).save()
await Comment.findOneAndUpdate({_id:commentId, postId},{$push:{replies:newReply._id}})
} catch(er){
console.log(er.message)
req.flash("error", `Failed to post a reply`)
}
}
res.redirect(`/${postId}#${commentId}`)
})
In the above route, we have two parameters, commentId
and postId
which are needed to identify where a particular reply belongs to. Again, we have used express-validator
for the input validation and islogin
middleware to check if the user is logged in or not. If there are no errors, first we have saved reply in a database with postId
and its parentComment
id. Then, replies
field of the parent comment is updated. Finally, the user is redirected to the same post page.
Route to delete comment
The route to delete a comment will be similar to the route to post a reply. It will also have two parameters commentId
and postId
as we need to know which comment to delete.
commentRoute.post("/:commentId/delete/:postId", islogin, async(req,res)=>{
const {commentId, postId} = req.params;
try{
const comment = await Comment.findOne({_id:commentId, postId}).lean()
if(comment && comment.postedBy.toString() === req.session.uid){
await Comment.deleteMany({_id:{$in:comment.replies}})
await Comment.deleteOne({_id:commentId})
}else{
req.flash("error", `Comment failed to delete`)
}
}catch(er){
console.log(er.message)
}
res.redirect(`/${postId}#comment-field`)
})
module.exports = commentRoute;
In the above code, first, we tried to find a comment using Comment.findOne()
method with the specified commentId and postId. If the comment is found and the user who posted it matches the currently logged-in user, it proceeds with the deletion process. Because only the user who posted that particular comment can delete that comment. We will show our UI according to this later on. If the logged-in user is the owner of that comment a delete button will be shown otherwise not.
In our deletion process, it first deletes all replies associated with that particular comment
await Comment.deleteMany({_id:{$in:comment.replies}})
and then, it deletes the comment itself await Comment.deleteOne({_id:commentId})
. If the deletion is successful, it redirects the user back to the post. If the comment is not found, or the user is not the owner of the post, it flashes an error message and redirects the user back to the post page.
Lastly, we have exported the CommentRoute
and we will import and use it in app.js
file
const commentRoute = require("./routes/comment");
app.use("/comment", commentRoute);
Now, let's look at what is happening in /${postId}
route. This route is handled in route.js
file.
router.get("/:postId", async(req,res)=>{
const{postId} = req.params;
try{
const post = await Posts.findById({_id: postId}).populate({path:"uid",select:"-password"}).lean();
const comments = await Comment.find({postId, parentComment:null}).sort({_id:1}).populate({path:"replies"}).populate({path:"postedBy", select:"-password"}).lean();
if(post){
res.render("singlePost",{
post,
comments,
userStatus
})
}
}catch(er){
console.log(er)
}
})
This route will send a single post to the user, first, it find a particular post with postId
and then, It will find the top-level comment associated with that post and populate replies
and postedBy
a user information who posted that particular comment. userStatus
will have currently logged-in user information if the user is logged-in otherwise it will return userStatus.login
to false
Comment input User Interface
We have set up all routes related to comments. Now, it's time to work on frontend. First up all, we will create UI to post a comment. I've created -commentInput.handlebars
inside partial
folder of view
directory. Following code goes inside this file:
{{> message}}
<div class = "comment-wrapper" id="comment-field">
<div class="user_pic">
<img alt="user-pic" src="{{userStatus.profileURL}}" />
</div>
<div class = "comment-input" >
<form action="/comment/{{post._id}}" method="post">
<textarea rows = "5" placeholder="Enter your comment" cols="40" name="comment"></textarea>
<br>
<button class="btn" type = "submit"> Submit </button>
</form>
</div>
</div>
Here, {{> message}}
is a partial template that will show an error message if there is any, and I've used it in singlePost.handlebars
as following:
{{#if userStatus.login}}
{{> -commentInput }}
{{/if}}
{{#if comments}}
{{> displayComment }}
{{else}}
<h5>No comments</h5>
{{/if}}
The comment input box will only be seen if the user is logged-in, and if the particular post has a comment, the comment will display. Now, we will see {{> displayComment}}
partial template in detail.
Rendering comment
Let's create another file displayComment.handlebars
in partial
folder in view
directory. displayComment.handlebars
will do following:
- Display comment and its reply recursively
- If user is not logged-in only comment will be seen
- If user is logged-in a
Reply
button should be there for user to put the reply - If user is logged-in and if there is any comment won by that user a
Reply
button as well as aDelete
button should be there so that user would be able to delete that comment
Following lines of code will in displayComment.handlebars
file:
<div class="comment">
{{#each comments}}
<div class = "single-cmt-card" id="{{this._id}}">
<div class = "card-header">
<div class="user_pic">
<a href="/user?id={{this.postedBy._id}}">
<img alt="user-pic" src="{{this.postedBy.profileURL}}" />
</a>
</div>
<div class = "user-name">
<b>{{this.postedBy.name}}</b>
<span class="post_date"> {{formatDate this.commentedAt}}</span>
</div>
</div>
<div class="card-body">
{{this.text}}
</div>
<div class = "action-btns">
{{#if (showBtns "reply")}}
<button class="reply-btn"> Reply
</button>
{{/if}}
{{#if (showBtns "delete" this.postedBy._id)}}
<form action="/comment/{{this._id}}/delete/{{this.postId}}" method="post" >
<button type="submit" class= "delete-btn"> Delete </button>
</form>
{{/if}}
</div>
{{#if (showBtns "reply")}}
<div class = "reply-cmt">
<form action="/comment/{{this._id}}/reply/{{this.postId}}" method="post">
<textarea required class="reply-textarea" name="replyText" rows="3" placeholder = 'Enter your reply'></textarea>
<div class = "action-btns">
<button type="submit"> Submit </button>
<button type="button" class = "cancel-btn"> Cancel </button>
</div>
</form>
</div>
{{/if}}
</div>
{{#if this.replies}}
<div class="reply" style="margin-left: 5px; border-left: solid 1px var(--primary-text-color);">
{{> displayComment comments = this.replies}}
</div>
{{/if}}
{{/each}}
</div>
The above code will display comments and their replies recursively. formatDate
and showBtns
are helper functions to display the comment's relative time, and conditionally display buttons based on user permissions. Reply
button triggers the display of a reply form for the comment which includes submit and cancel buttons to send or discard the reply. I've used a vanilla javascript to dynamically toggle the display of reply forms for individual comments. Delete
button submits a form to delete the comment.
Helper functions
Now, let's look at these two helper functions in details. I have created helperFunctions.js
file inside util
folder and following code will go in that file:
const {userStatus} = require("./userStatusChecker");
const moment = require("moment");
const formatDate = (date) =>{
return moment(date).fromNow()
}
const showBtns = (buttonType, userId) =>{
const {uid, login} = userStatus
switch(buttonType){
case "reply":
return login
case "delete":
return login && uid === userId.toString();
default:
return false
}
}
module.exports = {formatDate, showBtns}
In above code, formatDate(date)
formats a date into a more human-readable format, specifically using relative time expressions like "a few seconds ago", "5 minutes ago" etc. I've used moment
library for that and used moment(date).fromNow()
to generate a relative time.
showBtns(buttonType, userId)
conditionally determines wheather to display buttons based on user login status and user ID. userStatus
will retrieve user status from userStatusChecker
module. For reply
button type will return true
if user is logged-in. For delete
button type will return true
if user is logged-in and current user id is matched with comment posted user id otherwise return false
.
Finally, we need to register these helper functions in our app.js
file
const {formatDate, showBtns} = require("./utils/helperFunctions");
app.engine("handlebars", handlebars({
helpers:{
formatDate,
showBtns
}
}));
In this way, I have added nested comment system in my app. Finally, I would like to thank you for reading this post.