Create a Modern Application with Django and Vue #3

You can download the source code here.

Now that you know how to retrieve data using queries and how to send data using mutations, you can try something a little bit more challenging. In this article, let’s create a comment and a like reaction system for our blog project.

Before proceeding to the rest of this article, make sure you are familiar with Django and Vue.js. If not, please go through the following tutorials first:

Creating a comment system

Let’s start with the comment section. There are a few things you need to remember before diving into the code. First, for security reasons, only users that are logged in can leave comments. Second, each user can leave multiple comments, and each comment only belongs to one user. Third, each article can have multiple comments, and each comment only belongs to one article. Last but not least, the comment has to be approved by the admin before showing up on the article page.

Not logged in

Comment section not logged in

Logged in

Comment logged in

Setting up the backend

With that in mind, let’s start by creating the model for the comments. This part should be fairly easy to understand if you already know how to work with Django.

1
2
3
4
5
6
7
8
9
# Comment model
class Comment(models.Model):
    content = models.TextField(max_length=1000)
    created_at = models.DateField(auto_now_add=True)
    is_approved = models.BooleanField(default=False)

    # Each comment belongs to one user and one post
    user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
    post = models.ForeignKey(Post, on_delete=models.SET_NULL, null=True)

Next, go ahead and apply the changes you’ve made to the models. Go to the terminal and run the following commands.

1
python manage.py makemigrations
1
python manage.py migrate

You also need to set up GraphQL at the backend. You can add a type for the comment model.

1
2
3
class CommentType(DjangoObjectType):
    class Meta:
        model = models.Comment

And then the mutation. Note that there are three things Django needs to know to add a comment, the content of the comment, the user that wants to create this comment, and the article that the user is commenting on.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class CreateComment(graphene.Mutation):
    comment = graphene.Field(types.CommentType)

    class Arguments:
        content = graphene.String(required=True)
        user_id = graphene.ID(required=True)
        post_id = graphene.ID(required=True)

    def mutate(self, info, content, user_id, post_id):
        comment = models.Comment(
            content=content,
            user_id=user_id,
            post_id=post_id,
        )
        comment.save()

        return CreateComment(comment=comment)
1
2
3
class Mutation(graphene.ObjectType):
    . . .
    create_comment = CreateComment.Field()

Remember to add the CreateComment class inside the Mutation class.

Setting up the frontend

As for the frontend, let’s go to Post.vue, this is where the comments are shown. Please note that I removed some unrelated code in the following examples, so that the code snippets won’t be too long, but if you wish to have the complete code, you can download the source code here.

Post.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<script>
import { POST_BY_SLUG } from "@/queries";
import CommentSectionComponent from "@/components/CommentSection.vue";

export default {
  name: "PostView",

  components: { CommentSectionComponent },

  data() {
    return {
      postBySlug: null,
      comments: null,
      userID: null,
    };
  },

  computed: {
    // Filters out the unapproved comments
    approvedComments() {
      return this.comments.filter((comment) => comment.isApproved);
    },
  },

  async created() {
    // Get the post before the instance is mounted
    const post = await this.$apollo.query({
      query: POST_BY_SLUG,
      variables: {
        slug: this.$route.params.slug,
      },
    });
    this.postBySlug = post.data.postBySlug;
    this.comments = post.data.postBySlug.commentSet;
  },
};
</script>

queries.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
export const POST_BY_SLUG = gql`
  query ($slug: String!) {
    postBySlug(slug: $slug) {
      . . .
      commentSet {
        id
        content
        createdAt
        isApproved
        user {
          username
          avatar
        }
        numberOfLikes
        likes {
          id
        }
      }
    }
  }
`;

First, in the created() hook, you retrieve the requested article as well as the comments using the POST_BY_SLUG query, which is shown above. Next, in the computed property, you need to filter out the comments that are not approved by the admin. And finally, you pass the comment, the post ID and the user ID to the CommentSectionComponent.

CommentSectionComponent.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<template>
  <div class="home">
    . . .
    <!-- Comment Section -->
    <!-- Pass the approved comments, the user id and the post id to the comment section component -->
    <comment-section-component
      v-if="this.approvedComments"
      :comments="this.approvedComments"
      :postID="this.postBySlug.id"
      :userID="this.userID"
    ></comment-section-component>
  </div>
</template>

Next, let’s take a closer look at the comment section component. This component contains two sections, a form that allows the user to leave comments, which is only shown when the user is logged in, and a list of existing comments.

CommentSection.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<script>
import { SUBMIT_COMMENT } from "@/mutations";
import CommentSingle from "@/components/CommentSingle.vue";
import { useUserStore } from "@/stores/user";

export default {
  components: { CommentSingle },
  name: "CommentSectionComponent",

  setup() {
    const userStore = useUserStore();
    return { userStore };
  },

  data() {
    return {
      commentContent: "",
      commentSubmitSuccess: false,
      user: {
        isAuthenticated: false,
        token: this.userStore.getToken || "",
        info: this.userStore.getUser || {},
      },
    };
  },
  props: {
    comments: {
      type: Array,
      required: true,
    },
    postID: {
      type: String,
      required: true,
    },
    userID: {
      type: String,
      required: true,
    },
  },
  async created() {
    if (this.user.token) {
      this.user.isAuthenticated = true;
    }
  },
  methods: {
    submitComment() {
      if (this.commentContent !== "") {
        this.$apollo
          .mutate({
            mutation: SUBMIT_COMMENT,
            variables: {
              content: this.commentContent,
              userID: this.userID,
              postID: this.postID,
            },
          })
          .then(() => (this.commentSubmitSuccess = true));
      }
    },
  },
};
</script>

I assume you already know how to use Pinia to verify if the user is logged in, and how to use props to pass information between different components, I’ll skip this part, and let’s focus on the submitComment() method.

When this method is invoked, it will test if the comment is empty, and if not, it will use the SUBMIT_COMMENT mutation to create a new comment. The SUBMIT_COMMENT mutation is defined as follows:

mutations.js

1
2
3
4
5
6
7
8
9
export const SUBMIT_COMMENT = gql`
  mutation ($content: String!, $userID: ID!, $postID: ID!) {
    createComment(content: $content, userId: $userID, postId: $postID) {
      comment {
        content
      }
    }
  }
`;

The following code is the HTML section of CommentSection.vue file. Notice that at the end of this code, we used another component CommentSingle.vue to display one single comment.

CommentSection.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<template>
  <div class=". . .">
    <p class="font-bold text-2xl">Comments:</p>

    <!-- If the user is not authenticated -->
    <div v-if="!this.user.isAuthenticated">
      You need to
      <router-link to="/account">sign in</router-link>
      before you can leave your comment.
    </div>

    <!-- If the user is authenticated -->
    <div v-else>
      <div v-if="this.commentSubmitSuccess" class="">
        Your comment will show up here after is has been approved.
      </div>
      <form action="POST" @submit.prevent="submitComment">
        <textarea type="text" class=". . ." rows="5" v-model="commentContent" />

        <button class=". . .">Submit Comment</button>
      </form>
    </div>

    <!-- List all comments -->
    <comment-single
      v-for="comment in comments"
      :key="comment.id"
      :comment="comment"
      :userID="this.userID"
    >
    </comment-single>
  </div>
</template>

Now, let’s take a closer look at the CommentSingle.vue file.

CommentSingle.vue HTML section

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<template>
  <div class="border-2 p-4">
    <div
      class="flex flex-row justify-start content-center items-center space-x-2 mb-2"
    >
      <img
        :src="`http://127.0.0.1:8000/media/${this.comment.user.avatar}`"
        alt=""
        class="w-10"
      />
      <p class="text-lg font-sans font-bold">
        {{ this.comment.user.username }}
      </p>
    </div>

    <p>
      {{ this.comment.content }}
    </p>
  </div>
</template>

CommentSingle.vue JavaScript section

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<script>
export default {
  name: "CommentSingleComponent",
  data() {
    return {
      . . .
    };
  },
  props: {
    comment: {
      type: Object,
      required: true,
    },
    userID: {
      type: String,
      required: true,
    },
  },
};
</script>

Creating a like reaction system

Like system

As for the like system, there are also a few things you need to keep in mind. First, the user has to be logged in to add a like. Unverified users can only see the number of likes. Second, each user can only send one like to one article, and clicking the like button again would remove the like reaction. Lastly, each article can receive likes from multiple users.

Setting up the backend

Again, let’s start with the models.

Since each article can have many likes from many users, and each user can give many likes to many articles, this should be a many-to-many relationship between Post and User.

Also notice that this time a get_number_of_likes() function is created to return the total number of likes. Remember to apply these changes to the database using the commands we’ve talked about before.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Post model

class Post(models.Model):
    . . .

    # Each post can receive likes from multiple users, and each user can like multiple posts
    likes = models.ManyToManyField(User, related_name='post_like')

    . . .

    def get_number_of_likes(self):
        return self.likes.count()

Next, we add the types and mutations.

1
2
3
4
5
6
7
8
class PostType(DjangoObjectType):
    class Meta:
        model = models.Post

    number_of_likes = graphene.String()

    def resolve_number_of_likes(self, info):
        return self.get_number_of_likes()

Notice that in line 8, self.get_number_of_likes() invokes the get_number_of_likes() function you defined in the model.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class UpdatePostLike(graphene.Mutation):
    post = graphene.Field(types.PostType)

    class Arguments:
        post_id = graphene.ID(required=True)
        user_id = graphene.ID(required=True)

    def mutate(self, info, post_id, user_id):
        post = models.Post.objects.get(pk=post_id)

        if post.likes.filter(pk=user_id).exists():
            post.likes.remove(user_id)
        else:
            post.likes.add(user_id)

        post.save()

        return UpdatePostLike(post=post)

To add a like to a post, you need to know the id of the article, and the id of the user that likes this article.

From line 11 to 14, if the post already has a like from the current user, the like will be removed, and if not, a like will be added.

Setting up the frontend

Next, we need to add a like button to our post page. Go back to Post.vue.

Post.vue HTML section

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<template>
  <div class="home">
    . . .

    <!-- Like, Comment and Share -->
    <div class=". . .">
      <div v-if="this.liked === true" @click="this.updateLike()">
        <i class="fa-solid fa-thumbs-up">
          <span class="font-sans font-semibold ml-1">{{
            this.numberOfLikes
          }}</span>
        </i>
      </div>
      <div v-else @click="this.updateLike()">
        <i class="fa-regular fa-thumbs-up">
          <span class="font-sans font-semibold ml-1">{{
            this.numberOfLikes
          }}</span>
        </i>
      </div>
      . . .
    </div>

    . . .
  </div>
</template>

Post.vue JavaScript section

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<script>
import { POST_BY_SLUG } from "@/queries";
import { UPDATE_POST_LIKE } from "@/mutations";
. . .

export default {
  . . .
  async created() {
    . . .
    // Find if the current user has liked the post
    let likedUsers = this.postBySlug.likes;

    for (let likedUser in likedUsers) {
      if (likedUsers[likedUser].id === this.userID) {
        this.liked = true;
      }
    }

    // Get the number of likes
    this.numberOfLikes = parseInt(this.postBySlug.numberOfLikes);
  },

  methods: {
    updateLike() {
      if (this.liked === true) {
        this.numberOfLikes = this.numberOfLikes - 1;
      } else {
        this.numberOfLikes = this.numberOfLikes + 1;
      }
      this.liked = !this.liked;

      this.$apollo.mutate({
        mutation: UPDATE_POST_LIKE,
        variables: {
          postID: this.postBySlug.id,
          userID: this.userID,
        },
      });
    },
  },
};
</script>

I deleted some code to make this example shorter, but there are still four things we need to talk about in this example. First, the POST_BY_SLUG query that you use to retrieve the article, you need to make sure that it returns the number of likes and the users that already liked the article.

queries.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export const POST_BY_SLUG = gql`
  query ($slug: String!) {
    postBySlug(slug: $slug) {
      . . .
      numberOfLikes
      likes {
        id
      }
      . . .
    }
  }
`;

Next, in the created() hook, after you’ve retrieved the post, you must determine if the current user is in the list of users that already liked the post.

Then, in the updateLike() method, when this method is invoked, it will change the number of likes based on whether or not the user has liked the post.

Finally, the method updates the post’s likes in the backend using the UPDATE_POST_LIKE mutation.

mutations.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
export const UPDATE_POST_LIKE = gql`
  mutation ($postID: ID!, $userID: ID!) {
    updatePostLike(postId: $postID, userId: $userID) {
      post {
        id
        title
        likes {
          id
        }
      }
    }
  }
`;

A Challenge

After learning how to create a comment and a like system, let’s consider a more challenging task. What if we want to create a nested commenting system, where users can comment on another comment? How can we change our code to make this possible? And how can we create a like system for the comment as well?

The complete implementation of these functionalities are included in the source code of this tutorial.

comments powered by Disqus