Creating reusable components with Vue.js - Part 2 - Viewing Github Issue Comments

comments

Github Octocat Logo

About half-year ago I read Don Williamson's blog post on how to replace Disqus comments engine with Github Comments (in russian). "Brilliant idea!", I thought, and I was inspired to implement same comments support for my blog some day. The day has come!

Yes, really, I used Disqus for this blog from the start but never liked it much. Increased page load time, so many requests to unfamiliar and strange URLs - nothing good for me and the readers, I think.

Now I had a few spare time to implement comments with Github API. I had decided to touch with Vue.js for presentation and learn the way to implement reusable components with it.

Other posts in the series

  1. Creating reusable components with Vue.js - Part 1 - Tooling overview
  2. Creating reusable components with Vue.js - Part 2 - Viewing Github Issue Comments This post

Table of contents

Designing comments view component

Let's look at the typical comments view component. It will list all the comments for some page. Each comment has an author, comment text and publishing date.

First of all, we can identify "comment view" component and use it to render individual comments in list:

Typical comment has an author, comment text and publishing date

We can try to divide this component more:

  • "avatar" component, we can reuse it in comment posting form in future
  • "relative date" component (22 days ago on image above)

Relative date component

We'd like to pass some date to the component and it should render the string representation of this date relative to current date. Let's require to render date as relative if difference is less than month, and as ISO date otherwise. We'll store passed date in ISO format and in human-readable format in span's attribute just like GitHub does:

Let's create RelativeTime.vue component:

<template>
  <span
    :date="this.date.toISOString()"
    :title="this.date.toLocaleString()">
    {{this.relativeDate}}
  </span>
</template>
<script>
  export default {
    name: 'relative-time',
    props: {
      date: {
        default: new Date(),
        type: Date,
        required: true
      }
    },
    computed: {
      relativeDate: function () {
        const oneMinute = 60*1000
        const oneHour = 60*oneMinute
        const oneDay = 24*oneHour
        const oneMonth = 30*oneDay
        const difference = new Date() - this.date

        if (difference < oneMinute) {
          return 'just now'
        } else if (difference < oneHour) {
          return Math.floor(difference/oneMinute) + ' minutes ago'
        } else if (difference < oneDay) {
          return Math.floor(difference/oneHour) + ' hours ago'
        } else if (difference < oneMonth) {
          return Math.floor(difference/oneDay) + ' days ago'
        } else {
          return 'on ' + this.date.toLocaleString()
        }
      }
    }
  }
</script>

*.vue files are single file components, they consists of three sections: template, script and style. Each section is optional. You can specify language of each section with lang attribute. The defaults is lang="html" for template, lang="javascript" for script and lang="css" for style. If you use vue-loader and correctly configure your webpack build script, you'll be able, for example, to override defaults to lang="jade" for template, lang="typescript" for script section, lang="less" for style. Select what suits your needs, it's fully customizable!

The component receives single property date. We declare that property inside props: it should be Date, has default value and the value should be required.

Then we declare computed property relativeDate which is javascript function for calculation date relativeness to string.

Both date property and the relativeDate propery are used in template. Vue.js is smart enough to track changes in date property to update reactiveDate property value properly.

Avatar view component

Let's create AvatarView.vue component:

<template>
  <div class="avatar-container">
    <a :href="userUrl" rel="nofollow" target="_blank">
      <img :src="imageUrl" :alt="user" class="avatar"/>
    </a>
  </div>
</template>
<script>
  export default {
    name: 'avatar-view',
    props: [
      'user',
      'userUrl',
      'imageUrl'
    ]
  }
</script>
<style scoped>
  .avatar-container {
    border-radius: 5px;
    float: left;
    position: relative;
    margin-left: -65px;
  }

  .avatar {
    width: 50px;
    height: 50px;
    vertical-align: middle;
    border-radius: 5px;
    border-style: none;
    display: inline-block;
  }
</style>

The "avatar view" component receives three properties:

  • user name
  • GitHub's user profile URL
  • user's avatar image

There is no any required properties, so we'll left them without any declaration. We'll just use these properties in template and we need some styling to position avatar in the left. Nothing complicated here.

Github comment view component

Let's compose the avatar view and the relative date components to visualize single comment in GithubCommentView.vue component. I'll use bootstrap list-group-item styles here:

<template>
  <div>
    <avatar-view
      :user="userLogin"
      :userUrl="userProfileUrl"
      :imageUrl="userAvatarUrl"
    ></avatar-view>
    <ul :id="'issue-comment-' + commentId" class="list-group">
      <li class="list-group-item list-group-item-info header">
        <strong>
          <a :href="userProfileUrl" rel="nofollow" target="_blank">
            {{userLogin}}
          </a>
        </strong>
        commented
        <a :href="'#issue-comment-' + commentId" rel="nofollow">
          <relative-time :date="publishDate"></relative-time>
        </a>
      </li>
      <li class="list-group-item">
        <div v-html="commentHtmlBody"></div>
      </li>
    </ul>
  </div>
</template>
<script>
  import AvatarView from './AvatarView'
  import RelativeTime from './RelativeTime'
  export default {
    name: 'github-comment-view',
    props: [
      'commentId',
      'userLogin',
      'userProfileUrl',
      'userAvatarUrl',
      'publishDate',
      'commentHtmlBody'
    ],
    components: {
      RelativeTime,
      AvatarView
    },
  }
</script>

Unfortunately, template section expect the only one child node inside. Therefore we need to wrap multiple nodes in the root <div>.

You can easily insert HTML parts with v-html directive. Note that you should know what you doing - rendering data from user as HTML is potential security risk that allows for XSS attacks. I'll receive HTML data for this component from trusted GitHub API, therefore it's allowed.

This component (like RelativeTime and AvatarView) is "pure" - it just receives some properties and render HTML markup with them. The flow of properties directed from parent component to child.

GitHub comments list view component

We need to request issue comments using Github Issues Comments API. The HTTP request is simple: GET /repos/:owner/:repo/issues/:number/comments, no authentication and authorization needed.

Despite we can use AJAX requests to fetch the data, I decided to find ready convenient JavaScript client library. There is official list of client libraries for many languages, several wrappers written in JavaScript. I've selected octokat.js because it supports promises out of the box. It is small and can easily run in browser. I definitely recommend it to use!

The requirements for GitHub comments list component is:

  • show spinner icon when there is pending GitHub API request runs
  • load first portion of comments when component is created
  • "show more comments" button should load next portions of comments if possible

Here is the GithubCommentsListView.vuecomponent

<template>
  <div>
    <ul class="comments-list">
      <li v-for="comment in comments" class="comment-block" :key="comment.id">
        <github-comment-view
          :commentId="comment.id"
          :userLogin="comment.user.login"
          :userProfileUrl="comment.user.htmlUrl"
          :userAvatarUrl="comment.user.avatarUrl"
          :publishDate="comment.createdAt"
          :commentHtmlBody="comment.bodyHtml"
        ></github-comment-view>
      </li>
      <li class="comment-block">
        <div v-if="showLoader">
          <i class="fa fa-spin fa-spinner fa-fw fa-2x"></i>
          <span class="sr-only">Loading...</span>
        </div>
        <template v-if="!showLoader && canShowMoreComments">
          <button class="btn btn-success" @click="loadMoreComments">Show more comments</button>
          <span> of  comments shown</span>
        </template>
        <template v-if="!showLoader && !canShowMoreComments">
          <span> comments shown</span>
        </template>
      </li>
    </ul>
  </div>
</template>
<script>
  import Octokat from 'octokat'
  import GithubCommentView from './GithubCommentView'
  export default {
    name: 'github-comments-list-view',
    props: [
      'apiRoot',
      'owner',
      'repository',
      'issueNumber'
    ],
    components: {
      GithubCommentView
    },
    data: function () {
      return {
        comments: [],
        overallCommentsCount: 0,
        canShowMoreComments: false,
        showLoader: false
      }
    },
    created: async function() {
      this.showLoader = true
      const octo = new Octokat({
        rootURL: this.apiRoot,
        acceptHeader: 'application/vnd.github.v3.html+json'
      })
      const issueRoot = octo
        .repos(this.owner, this.repository)
        .issues(this.issueNumber)

      const issue = await issueRoot.fetch()
      this.overallCommentsCount = issue.comments

      const comments = await issueRoot.comments.fetch()
      this.addComments(comments)
    },
    methods: {
      addComments: function (comments) {
        this.comments = this.comments.concat(comments.items)
        this.nextComments = comments.nextPage
        this.canShowMoreComments = !!comments.nextPage
        this.showLoader = false
      },
      loadMoreComments: function () {
        if (!this.showLoader && this.nextComments) {
          this.showLoader = true
          this.nextComments.fetch().then(this.addComments)
        }
      }
    }
  }
</script>
<style scoped>
  .comments-list {
    list-style-type: none;
  }

  .comment-block {
    margin: 20px 20px 20px 80px;
  }
</style>

This component is not pure. It's aggregation root for comments list and holds state: current loaded list of comments.

I've used created lifecycle function to load first portion of comments. It is necessary to configure octokat.js to accept application/vnd.github.v3.html+json MIME type so we can receive plain HTML instead of markdown for comments body.

The addComments method pushes loaded comments to array and resets showLoader flag. If you can load more comments, nextComments will be filled and "Show more comments" button will be shown.

In the template, GitHub comments rendered from array using v-for directive. It worth to mention key attribute which exists to help Vue.js to reuse existing elements if underlying array has been changed.

Why I pass individual properties to github-comment-view not the GitHub's comment? Because of open-closed principle. github-comment-view has well-defined interface and closed for possible changes in Octokat or GitHub API.

For markup, I used boostrap styles and spinner icon from FontAwesome.

Conclusion

It was interesting journey to reusable components and it's not over. You can find the source code for this blog post on GitHub.

There are multiple topics out of this blog post like:

  • state managed using stores with Vuex
  • modifying markup and styles of ready components
  • DI, slots, plugins and mixins

In the next post I'll explore how to integrate GitHub Issue Comments view component to Jekyll-based blog.

Happy commenting!

Comments