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.

  1. 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.

  2. 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 the State 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, and clearComments 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 the State type.

      type State = {
    +   selectedPostId: string | null;
      };
    
      // define the initial state
      const initialState: State = {
    +   selectedPostId: null,
      };
    
  • Introduce actions like setSelectedPostId and clearSelectedPostId to manage the selectedPostId 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 the useEffect. Additionally, we clear the comments if there is a failure in fetching them for any reason.

  • useMutationComments: This hook handles adding new comments using the createComment 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:

  1. 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;
    
  2. 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;
    
  3. 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 the Comments 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 in app/src/components/post to pass the post object to PostFooter:

      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 in app/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:

  1. Start the Application.
  2. Log in as a user.
  3. Navigate to existing posts.
  4. Attempt to add comments to these posts.
  5. Observe the update in comment counts when new comments are added.
  6. View the list of comments on different posts.
  7. Refresh the page to see if comments persist.
  8. Test various erroneous scenarios, such as adding comments without content.
  9. 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"