Task 15: Comment on Posts — Frontend
In this task, we will focus on enabling users to comment on posts in our frontend application. This involves updating API functions, store management, custom hooks, UI components, and integrating a dialog for adding comments.
Step 1: Update API Functions
Update Type Declaration
First, we update the type declarations to include a new type that combines Comment
with optional User
data. This is done in the app/src/lib/types.ts
file:
export type CommentWithUserData = Comment & { user?: User };
Update API Functions
Next, we update the API functions in app/src/lib/api.ts
by adding two new functions: fetchComments
and createComment
.
-
The
fetchComments
function fetches all comments for a specific post// Fetch all comments for a post export const fetchComments = async (postId: string): Promise<CommentWithUserData[]> => { const token = getAuthenticatedUserToken(); const response = await fetch( `${API_URL}/posts/${postId}/comments?withUserData=true`, { method: "GET", headers: { Authorization: `Bearer ${token}`, }, }, ); const responseJson = await response.json(); if (!response.ok) { handleError(response, responseJson.message); } return responseJson.data; };
Note that we need to provide the bearer token because this endpoint is protected. Additionally, we use the
withUserData
flag to retrieve the comment along with user data. -
The
createComment
is used to post a new comment:// Create a new comment export const createComment = async ( postId: string, content: string, ): Promise<CommentWithUserData> => { const user = getAuthenticatedUser(); const token = getAuthenticatedUserToken(); const response = await fetch(`${API_URL}/posts/${postId}/comments`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ content }), }); const responseJson = await response.json(); if (!response.ok) { handleError(response, responseJson.message); } return { ...responseJson.data, user, }; };
These functions handle API calls for fetching and creating comments, respectively. They follow a similar pattern for error handling as other API functions in that file.
After making these updates, we will stage and commit the changes:
git add .
git commit -m "Update API functions for comments"
Step 2: Update the Store
Next, we update app/src/lib/store.ts
to include new state and actions related to comments:
-
Add
comments
to theState
type.- import { PostWithUserData, User } from "./types"; + import { CommentWithUserData, PostWithUserData, User } from "./types"; type State = { + comments: CommentWithUserData[]; }; // define the initial state const initialState: State = { + comments: [], };
-
Introduce actions like
setComments
,addComment
, andclearComments
to manage the comments in the global state.type Action = { + setComments: (comments: CommentWithUserData[]) => void; + addComment: (comment: CommentWithUserData) => void; + clearComments: () => void; };
-
Implement the corresponding functions within the store to handle these actions.
setComments: (comments) => set({ comments }), addComment: (comment) => { set({ comments: [comment, ...get().comments], posts: get().posts.map((post) => { if (post.id === comment.postId) { return { ...post, commentCount: post.commentCount + 1, }; } return post; }), }); }, clearComments: () => set({ comments: [] }),
When we add a comment to a post, we also need to update the comment count for that post. If we don't update it ourselves, we would have to fetch the post (or all posts) in order for the comment count to reflect the addition.
Let’s revisit the newly added comments
array to the store! Should this contain all the comments? It would not be a scalable solution if we were to store all the comments in this array. Moreover, if we fetch a paginated set of comments, it will include the latest comments placed on some of the posts in our app. This sporadic spread will not be helpful in displaying the comments. A more viable solution is to store the (paginated set of) comments for a particular post when the user selects that post to view its corresponding comments. To that aim, we’ll add another variable to the store:
-
Add
comments
to theState
type.type State = { + selectedPostId: string | null; }; // define the initial state const initialState: State = { + selectedPostId: null, };
-
Introduce actions like
setSelectedPostId
andclearSelectedPostId
to manage theselectedPostId
in the global state.type Action = { + setSelectedPostId: (id: string) => void; + clearSelectedPostId: () => void; };
-
Implement the corresponding functions within the store to handle these actions.
setSelectedPostId: (id) => set({ selectedPostId: id }), clearSelectedPostId: () => set({ selectedPostId: null }),
This update enhances the global state management to include functionalities for storing and manipulating comment data.
Once these changes are made, we will stage and commit them:
git add .
git commit -m "Update store for comment management"
Step 3: Update Custom Hooks
Now, we add two custom hooks in app/src/hooks
: use-query-comments.tsx
and use-mutations-comments.tsx
.
-
useQueryComments
: This hook is used for fetching and setting comments in the store. It also handles clearing comments and displaying toasts for errors.import { useToast } from "@/components/ui/use-toast"; import { fetchComments } from "@/lib/api"; import { useStore } from "@/lib/store"; import { useEffect } from "react"; function useQueryComments() { const { toast } = useToast(); const comments = useStore((state) => state.comments); const setComments = useStore((state) => state.setComments); const clearComments = useStore((state) => state.clearComments); const selectedPostId = useStore((state) => state.selectedPostId); const loadComments = async () => { try { const fetchedComments = await fetchComments(selectedPostId as string); setComments(fetchedComments); } catch (error) { clearComments(); toast({ variant: "destructive", title: "Failed to fetch comments", description: (error as Error).message || "There was an error loading the comments. Please try again later.", }); } }; useEffect(() => { if (selectedPostId) { loadComments(); } else { clearComments(); } }, [selectedPostId]); return { comments }; } export default useQueryComments;
Notice how we use the
selectedPostId
to trigger the loading or clearing of comments in theuseEffect
. Additionally, we clear the comments if there is a failure in fetching them for any reason. -
useMutationComments
: This hook handles adding new comments using thecreateComment
API function. It also updates the store and shows toasts for any errors.import { createComment } from "@/lib/api"; import { useStore } from "@/lib/store"; import { useToast } from "@/components/ui/use-toast"; function useMutationComments() { const { toast } = useToast(); const addComment = useStore((state) => state.addComment); const selectedPostId = useStore((state) => state.selectedPostId); const addNewComment = async (content: string) => { try { const newComment = await createComment(selectedPostId as string, content); addComment(newComment); } catch (error) { toast({ variant: "destructive", title: "Failed to create the comment", description: (error as Error).message || "There was an error creating the comment. Please try again later.", }); } }; return { addNewComment }; } export default useMutationComments;
With these hooks in place, we further enhance the application's functionality for handling comment-related actions.
After completing these hooks, we stage and commit our changes:
git add .
git commit -m "Add custom hooks for querying and mutating comments"
Step 4: Comment UI Components
We need to add UI components related to comments. Before doing so, let’s refactor app/src/components
and organize the related files into folders:
.
├── auth
│ ├── login-dialog.tsx
│ ├── logout-dialog.tsx
│ └── register-dialog.tsx
├── feed.tsx
├── header.tsx
├── post
│ ├── add-post-dialog.tsx
│ ├── post-actions.tsx
│ ├── post-avatar.tsx
│ ├── post-footer.tsx
│ ├── post-header.tsx
│ ├── post.tsx
│ └── posts.tsx
├── sidebar.tsx
└── ui
We proceed to include a new comment
folder. This folder will contain components related to comments:
-
comment-header.tsx
: A component for the header of a comment, displaying the author's details and the timestamp.import { formatTimestamp } from "@/lib/utils"; type CommentHeaderProps = { name: string; // author's display name username: string; // author's username timestamp: string; // post's timestamp }; const CommentHeader = ({ name, username, timestamp }: CommentHeaderProps) => { return ( <div className="flex justify-between text-sm text-muted-foreground"> <p> {name} ({`@${username}`}) </p> <p>{formatTimestamp(timestamp)}</p> </div> ); }; export default CommentHeader;
-
comment.tsx
: A component representing a single comment.import CommentHeader from "./comment-header"; import type { CommentWithUserData } from "@/lib/types"; const Comment = ({ comment }: { comment: CommentWithUserData }) => { const { content, user, timestamp } = comment; const displayName = user?.displayName ?? "Unknown"; const username = user?.username ?? "Unknown"; return ( <div className="flex border-b border-slate-400"> <div className="w-full p-4"> <CommentHeader name={displayName} username={username} timestamp={timestamp} /> <div className="mt-2">{content}</div> </div> </div> ); }; export default Comment;
-
comments.tsx
: A component for displaying a list of comments.import useQueryComments from "@/hooks/use-query-comments"; import Comment from "./comment"; const Comments = () => { const { comments } = useQueryComments(); if (comments.length === 0) { return ( <div className="p-4 text-center border-b border-slate-400"> No comments yet. </div> ); } return ( <div> {comments.map((comment) => ( <Comment comment={comment} key={comment.id} /> ))} </div> ); }; export default Comments;
These components are crucial for displaying comments and their details in the user interface.
After implementing these components, we stage and commit our work:
git add .
git commit -m "Implement comment UI components"
Step 5: Update Post UI Components
Next, we update the existing post components to integrate comments functionality:
-
posts.tsx
: Modified to conditionally render theComments
component based on the selected post ID.import { useStore } from "@/lib/store"; import Post from "./post"; import useQueryPosts from "@/hooks/use-query-posts"; import Comments from "../comment/comments"; const Posts = () => { const { posts } = useQueryPosts(); const selectedPostId = useStore((state) => state.selectedPostId); return ( <div className=""> {posts.map((post) => ( <div key={post.id}> <Post post={post} /> {post.id === selectedPostId && <Comments />} </div> ))} </div> ); }; export default Posts;
-
post-footer.tsx
: Updated to show comments when comment (ChatBubbleIcon
) is clicked clicked.import { Button } from "@/components/ui/button"; import { ChatBubbleIcon, HeartIcon } from "@radix-ui/react-icons"; import PostActions from "./post-actions"; import { SyntheticEvent, useEffect, useState } from "react"; import { useStore } from "@/lib/store"; const PostFooter = ({ postId, username, }: { postId: string; username?: string; }) => { const [likes, setLikes] = useState(0); const selectedPostId = useStore((state) => state.selectedPostId); const setSelectedPostId = useStore((state) => state.setSelectedPostId); const clearSelectedPostId = useStore((state) => state.clearSelectedPostId); const showComments = (event: SyntheticEvent) => { event.preventDefault(); if (selectedPostId === postId) { clearSelectedPostId(); } else { setSelectedPostId(postId); } }; return ( <div className="flex justify-around mb-4"> <Button variant="ghost" size="sm" onClick={() => setLikes(likes + 1)}> <HeartIcon className="w-5 h-5" /> {likes > 0 && <sup>{likes}</sup>} </Button> <Button variant="ghost" size="sm" onClick={showComments}> <ChatBubbleIcon className="w-5 h-5" /> </Button> <PostActions postId={postId} username={username} /> </div> ); }; export default PostFooter;
These updates ensure that the comments feature is seamlessly integrated into the existing post UI components.
Following these updates, we stage and commit the changes:
git add .
git commit -m "Update post UI components to integrate comments"
Step 6: Add Comment Dialog
Let’s add a new file add-comment-dialog.tsx
in the app/src/components/comment
folder:
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Textarea } from "../ui/textarea";
import { useToast } from "@/components/ui/use-toast";
import { useStore } from "@/lib/store";
import useMutationComments from "@/hooks/use-mutations-comments";
export const AddCommentDialog = () => {
const [content, setContent] = useState("");
const { addNewComment } = useMutationComments();
const { toast } = useToast();
const user = useStore((state) => state.user);
const handleSave = async () => {
if (!content) {
toast({
variant: "destructive",
title: "Sorry! Content cannot be empty! 🙁",
description: `Please enter the content for your comment.`,
});
return;
}
await addNewComment(content);
setContent("");
};
const handleCancel = () => {
setContent("");
};
return (
<Dialog>
<DialogTrigger asChild>
<Button
aria-label={"Make a Comment"}
variant="secondary"
size="sm"
className="w-full m-2"
>
Add Comment
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Add Comment</DialogTitle>
<DialogDescription>
{user
? "Provide the content of your comment here."
: "Please login to make a post."}
</DialogDescription>
</DialogHeader>
{user && (
<div className="grid gap-4 py-4">
<div className="grid items-center grid-cols-4 gap-4">
<Textarea
id="content"
value={content}
className="col-span-4"
onChange={(e) => {
setContent(e.target.value);
}}
/>
</div>
</div>
)}
<DialogFooter>
{!user && (
<DialogClose asChild>
<Button>Okay</Button>
</DialogClose>
)}
{user && (
<DialogClose asChild>
<Button variant={"secondary"} type="reset" onClick={handleCancel}>
Cancel
</Button>
</DialogClose>
)}
{user && (
<DialogClose asChild>
<Button type="submit" onClick={handleSave}>
Save
</Button>
</DialogClose>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
This dialog component is used for adding new comments. It includes form controls and handles the submission of new comments.
This dialog provides an interactive way for users to add comments to posts.
Let's update the Comments
component to include the AddCommentDialog
. Additionally, let's account for the scenario where the user is not logged in.
import useQueryComments from "@/hooks/use-query-comments";
import Comment from "./comment";
import { AddCommentDialog } from "./add-comment-dialog";
import { useStore } from "@/lib/store";
const Comments = () => {
const { comments } = useQueryComments();
const user = useStore((state) => state.user);
if (!user) {
return (
<div className="p-4 text-center border-b border-slate-400">
Please sign in to see the comments
</div>
);
}
return (
<div>
<div className="flex items-center justify-center">
<AddCommentDialog />
</div>
<div>
{comments.length === 0 ? (
<div className="p-4 text-center border-b border-slate-400">
No comments yet.
</div>
) : (
comments.map((comment) => (
<Comment comment={comment} key={comment.id} />
))
)}
</div>
</div>
);
};
export default Comments;
Once this component is created, we stage and commit the changes:
git add .
git commit -m "Add AddCommentDialog component"
Step 7: Show Comment Count
Finally, we make further updates to enhance comment-related features:
-
Update
posts.tsx
inapp/src/components/post
to pass the post object toPostFooter
:import PostAvatar from "./post-avatar"; import PostHeader from "./post-header"; import PostFooter from "./post-footer"; import type { PostWithUserData } from "@/lib/types"; const Post = ({ post }: { post: PostWithUserData }) => { - const { id, content, user, timestamp } = post; + const { content, user, timestamp } = post; // The code below uses Optional Chaining (?.) and Nullish Coalescing (??) const displayName = user?.displayName ?? "Unknown"; const username = user?.username ?? "Unknown"; const avatar = user?.avatar; return ( <div className="flex border-b border-slate-400"> <div className="p-4"> <PostAvatar imageUrl={avatar} displayName={displayName} /> </div> <div className="w-full pt-4 pr-4"> <PostHeader name={displayName} username={username} timestamp={timestamp} /> <div className="my-4">{content}</div> - <PostFooter postID={id} username={username} /> + <PostFooter post={post} username={username} /> </div> </div> ); }; export default Post;
-
Modify
post-footer.tsx
inapp/src/components/post
to show comment count.import { Button } from "@/components/ui/button"; import { ChatBubbleIcon, HeartIcon } from "@radix-ui/react-icons"; import PostActions from "./post-actions"; import { SyntheticEvent, useEffect, useState } from "react"; import { useStore } from "@/lib/store"; import { PostWithUserData } from "@/lib/types"; const PostFooter = ({ post, username, }: { post: PostWithUserData; username?: string; }) => { const { id: postId, likeCount, commentCount } = post; const [likes, setLikes] = useState(0); const [comments, setComments] = useState(0); const selectedPostId = useStore((state) => state.selectedPostId); const setSelectedPostId = useStore((state) => state.setSelectedPostId); const clearSelectedPostId = useStore((state) => state.clearSelectedPostId); const showComments = (event: SyntheticEvent) => { event.preventDefault(); if (selectedPostId === postId) { clearSelectedPostId(); } else { setSelectedPostId(postId); } }; useEffect(() => { if (likeCount !== likes) { setLikes(likeCount); } }, [likeCount]); useEffect(() => { if (commentCount !== comments) { setComments(commentCount); } }, [commentCount]); return ( <div className="flex justify-around mb-4"> <Button variant="ghost" size="sm" onClick={() => setLikes(likes + 1)}> <HeartIcon className="w-5 h-5" /> {likes > 0 && <sup>{likes}</sup>} </Button> <Button variant="ghost" size="sm" onClick={showComments}> <ChatBubbleIcon className="w-5 h-5" /> {comments > 0 && <sup>{comments}</sup>} </Button> <PostActions postId={postId} username={username} /> </div> ); }; export default PostFooter;
These changes provide a more dynamic and informative user interface regarding comments on posts.
After making these final updates, we stage and commit our changes:
git add .
git commit -m "Enhance UI to show comment count and integrate AddCommentDialog"
Step 8: Testing the Application
To conclude this task, follow these steps to test the newly implemented features:
- Start the Application.
- Log in as a user.
- Navigate to existing posts.
- Attempt to add comments to these posts.
- Observe the update in comment counts when new comments are added.
- View the list of comments on different posts.
- Refresh the page to see if comments persist.
- Test various erroneous scenarios, such as adding comments without content.
- Check the user interface for any layout issues or bugs.
After testing, if any changes were made to the code based on the testing, stage and commit these changes using the following commands:
git add .
git commit -m "Final adjustments after testing comments feature"