Building A Comments System With Vue.js, Laravel, and Tailwind CSS Part I

Nick Basile • April 18, 2018

Heads up! This post is over a year old, so it might be out of date.

This weekend, I was hacking away on a comments system for my SaaS app Lean Steps, and about half-way through I realized that it would make a great blog post. So, here we are!

In this first post, we'll start our comments system by building a Vue.js component. Then in the next few installments, we can style it with Tailwind CSS and wire it up to a Laravel backend to persist our comments.

Let's dive right in and get to work!

The Set Up

We'll be using Laravel for our backend, so we can start by setting up a new Laravel project. To do that we can navigate to where we want to store our project in our terminal and then run laravel new comments (feel free to call the project whatever you'd like).

Once our project is done installing, we'll need to update some of the .env variables. Opening up our project, we can see the .env file in the root of our project. Inside we'll update the APP_NAME and APP_URL to "Comments Demo" and "http://comments.test" respectively.

In case you were wondering, I'm getting that URL from Laravel Valet. If you're interested in getting set up with that, check out their docs here.

Now we can update our database details. I have a generic demo database set up that I use for all of my quick projects. I think it's nice because I don't have to spin up a new database for each project.

If you don't already have one set up, you can run mysql -uroot -p to sign into MySQL and then run create database <database-name>; to create a new database.

With that setup, in our .env we can update our DB_DATABASE to match our database name. Our DB_USERNAME can be set to root, and we can leave our DB_PASSWORD blank.

We'll be using Laravel's default authentication scaffolding so we can record which users left comments. So, we'll scaffold that by running php artisan make:auth.

Now, we're ready to migrate our database. To do that, we'll run php artisan migrate from the command line.

Onto our JavaScript build, we'll initialize all of our dependencies by running yarn. Then we can add in Tailwind CSS by following the installation instructions provided in their docs.

Finally, we'll initialize our git repository and commit all of this set up by running git init, git add ., and git commit -m "initial commit".

That'll get us up and running for this project. Now let's start building our comments component.

Scaffolding The Component

We can start building our comments component by running npm run watch so our JavaScript changes get compiled as we work. Next, we'll hop into our home.blade.php file and clear out the existing code between the @section() tags, so we have some room to add our comments.

In our browser, we can quickly run through the register flow to create an account so that we can see the home.blade.php template in action. With everything cleared out we should see this:

With that setup, we can head over to our resources/asset/js/components directory and add a new Vue file called CommentsManager.vue. Inside of there, we'll scaffold it out with the following:

<template>
    <div>
        <h1>Hello World</h1>
    </div>
</template>

<script>
    export default {
        data: function() {
            return {

          }
        },
    }
</script>

Nice! Now, we can register our CommentsManager component in our app.js file like so:

Vue.component('comments-manager', require('./components/CommentsManager.vue'));

Finally, we can add our component to the home.blade.php file so we can see it in our browser. To do that, we'll add the component in between the @section() tags like this:

@extends('layouts.app')

@section('content')
    <comments-manager></comments-manager>
@endsection

Back in our browser, we can now see that our component is being rendered.


Not the prettiest UI just yet, but we'll get there!

Showing Comments

With that all set up, let's start showing some comments. Inside of our CommentsManager's data property, we can add an Array for comments. Inside, we'll add a basic comment with some details we're going to need.

data: function() {
    return {
        comments: [
            {
                id: 1,
                body: "How's it going?",
                edited: false,
                created_at: new Date().toLocaleString(),
                author: {
                    id: 1,
                    name: 'Nick Basile',
                }
            }
        ]
    }
},    

Now that we have some data to work with we can start showing it on the page. At this point, we have a choice to make. While we could keep all of our logic contained within the CommentsManager component, we'd end up having to write some weird methods to parse each comment's details. So, let's make another component for each comment.

In the same directory as CommentsManager, we can create a new Vue file called CommentItem. We can scaffold it the same way as CommentsManager.

<template>
    <div>

    </div>
</template>

<script>
    export default {
        data: function() {
            return {

          }
        },
    }
</script>

Now, let's pass through a comment prop with all of the comment's data and then add it to the template.

<template>
    <div>
        <div>
            <p>{{comment.body}}</p>
        </div>
        <div>
            <p>{{comment.author.full_name}} <span>•</span>{{ comment.created_at}}</p>
        </div>
    </div>
</template>

<script>
    export default {
        props: {
            comment: {
                required: true,
                type: Object,
            }
        },
        data: function() {
            return {

          }
        },
    }
</script>

With our component in place, we can register it in our CommentsManager and start showing the comments. Right below our opening script tag, we can import our CommentItem. Then we can add a components property to CommentsManager and register our CommentItem. To wrap this up, we'll add our comment to the template, add a v-for and :key directives, and add our comment prop.

<template>
    <div>
        <div>
            <comment v-for="comment in comments"
                     :key="comment.id"
                     :comment="comment">
            </comment>
        </div>
    </div>
</template>

<script>
    import comment from './CommentItem'
    export default {
        components: {
            comment
        },
        data: function() {
            return {
                comments: [
                    {
                        id: 1,
                        body: "How's it going?",
                        edited: false,
                        created_at: new Date().toLocaleString(),
                        author: {
                            id: 1,
                            name: 'Nick Basile',
                        }
                    }
                ]
          }
        },
    }
</script>

Now our page is starting to come to life.

Let's keep working on out CommentItem component and add the ability to edit a comment.

Editing a Comment

As we build the edit functionality, it's important to remember that we don't want to let just anybody edit a comment. We only want the author to edit it. To make that happen, we'll need to pass the current user to our CommentItem.

For that, we'll need to add a user prop to both our components and then add pass through the authenticated user. We can go ahead and add that prop like this;

//CommentsManager
<comment v-for="comment in comments"
         :key="comment.id"
         :user="user"
         :comment="comment">
</comment>
...
props: {
    user: {
        required: true,
        type: Object,
    }
},

//CommentItem
props: {
    user: {
        required: true,
        type: Object,
    },
    comment: {
        required: true,
        type: Object,
    }
},

//home.blade.php
<comments-manager :user="{{ auth()->user() }}"></comments-manager>

Now in our CommentItem, we can add a computed property to check if the current user can edit this comment.

computed: {
    editable() {
        return this.user.id === this.comment.author.id;
    }
}

It's a pretty simple check, but having it in a computed property will make our template much more readable.

Now we can add an edit button for our comment author. Right below our comment.body, we can add the button.

<div>
    <p>{{comment.body}}</p>
    <button v-if="editable">Edit</button>
</div>

With that v-if in place, we can be sure that only the author will be able to click that button. Let's add another comment now so we can see both states in action. So in the CommentsManager inside of comments, we can add another Object.

comments: [
    {
        id: 1,
        body: "How's it going?",
        edited: false,
        created_at: new Date().toLocaleString(),
        author: {
            id: 1,
            name: 'Nick Basile',
        }
    },
    {
        id: 2,
        body: "Pretty good. Just making a painting.",
        edited: false,
        created_at: new Date().toLocaleString(),
        author: {
            id: 2,
            name: 'Bob Ross',
        }
    }
]

Looking at our page, we can see that we can't edit Bob's comment because we're not the author of it.

That's pretty awesome. Now, let's get to work editing our comment.

We'll start by adding a state property to our CommentItem's component's data so we can keep track of when we're editing. Let's also add a updatedComment property so we can keep track of our changes.

data: function() {
    return {
        state: 'default',
        data: {
            body: this.comment.body,
        }
  }
},

Since this is a pretty simple state, I'll go ahead and use a String. But, feel free to use a Boolean if you'd like. For a more in-depth look at when to use which, check out my previous blog post about it.

Initializing our data.body with the value from the comment.body allows us to populate our edit form with the correct initial data.

Now, we'll need wire up our edit button to toggle the state. We can simply bind a click event that sets the state to editing.

<button v-if="editable" @click="state = 'editing'">Edit</button>

With that hooked up, let's add in our edit form and make sure we're toggling our read and edit states.

<template>
    <div>
        <div v-show="state === 'default'">
            <div>
                <p>{{comment.body}}</p>
                <button v-if="editable" @click="state = 'editing'">Edit</button>
            </div>
            <div>
                <p>{{comment.author.full_name}} <span>•</span>{{ comment.created_at}}</p>
            </div>
        </div>
        <div v-show="state === 'editing'">
            <div>
                <h3>Update Comment</h3>
            </div>
            <textarea v-model="data.body"
                      placeholder="Update comment"
                      class="border">
            </textarea>
            <div>
                <button>Update</button>
                <button>Cancel</button>
            </div>
        </div>
    </div>
</template>

Everything here is pretty straight-forward. We have two divs that we toggle when the state changes. The first div has our read state from earlier, while the second contains a textarea and some buttons. Let's wire up the cancel button first so we can toggle the editing state.

We can add a method called resetEdit that will toggle the state and reset the data.body information. Then we'll bind that to our cancel button.

//Template
<button @click="resetEdit">Cancel</button>
...

//Vue Instance
methods: {
    resetEdit() {
        this.state = 'default';
        this.data.body = this.comment.body;
    }
}

Now we can make all the changes we want and leave before saving them.

Let's continue by persisting our edits. We'll make another method and call it saveEdit(). We can then bind that to our save button.

Here we have a couple of choices for bubbling our changes back up to the parent component where comments live. We could use $parent.comments to access them directly, or we could directly change our comment prop. These aren't the best options though, because they tightly couple our components together. That isn't always the worst thing in the world, but let's use events for a more "proper" approach.

In our saveEdit method, we'll set the state back to its default value, and we'll $emit a comment-updated event with the data we'll need to make this edit. Then in our CommentsManager, we'll have an updateComment method that will get called when our event fires.

//CommentItem

//Template
<button @click="saveEdit">Update</button>

//Script
saveEdit() {
    this.state = 'default';

    this.$emit('comment-updated', {
        'id': this.comment.id,
        'body': this.data.body,
    });
}

//CommentsManager

//Template
<comment v-for="comment in comments"
         :key="comment.id"
         :user="user"
         :comment="comment"
         @comment-updated="updateComment($event)">
</comment>

//Script
methods: {
    updateComment($event) {
        let index = this.comments.findIndex((element) => {
            return element.id === $event.id;
        });

        this.comments[index].body = $event.body;
    }
}

Hopping into our browser, we can see that our comment is getting updated now.

Deleting a Comment

With this same approach, we can also add the functionality to delete a comment. Let's add a delete button to our template and wire up an event.

//CommentItem

//Template
<div>
    <button @click="saveEdit">Update</button>
    <button @click="resetEdit">Cancel</button>
    <button @click="deleteComment">Delete</button>
</div>

//Script
deleteComment() {
    this.$emit('comment-deleted', {
        'id': this.comment.id,
    });
}

//Comments Manager

//Template
<comment v-for="comment in comments"
         :key="comment.id"
         :user="user"
         :comment="comment"
         @comment-updated="updateComment($event)"
         @comment-deleted="deleteComment($event)">
</comment>

//Script
deleteComment($event) {
    let index = this.comments.findIndex((element) => {
        return element.id === $event.id;
    });

    this.comments.splice(index, 1);
}

Once we get the hang of using events to manipulate data, it gets pretty easy to build functionality like this. Now that we can edit and delete our comments let's add a form for adding new comments.

Adding Comments

If you've been keeping score at home, we're down to our final CRUD action: creating new comments. We'll keep things simple and add a form directly to our CommentsManager.

Right above our comment v-for we can add a simple form that looks a look like our edit form.

<template>
    <div>
        <div>
            <div>
                <h2>Comments</h2>
            </div>
            <textarea placeholder="Add a comment"
                          class="border">
              </textarea>
            <div>
                <button>Save</button>
                <button>Cancel</button>
            </div>
        </div>
        <div>
            <comment v-for="comment in comments"
                     :key="comment.id"
                     :user="user"
                     :comment="comment"
                     @comment-updated="updateComment($event)"
                     @comment-deleted="deleteComment($event)">
            </comment>
        </div>
    </div>
</template>

To start adding new offers, we'll need to add a saveComment method, add a data.body property to our data, and add a v-model to our textarea.

//Template
 <div>
    <div>
        <h2>Comments</h2>
    </div>
    <textarea v-model="data.body"
              placeholder="Add a comment"
              class="border">
    </textarea>
    <div>
        <button @click="saveComment">Save</button>
        <button>Cancel</button>
    </div>
</div>

//Script
saveComment() {
    let newComment = {
        id: this.comments[this.comments.length - 1].id + 1,
        body: this.data.body,
        edited: false,
        created_at: new Date().toLocaleString(),
        author: {
            id: this.user.id,
            name: this.user.name,
        }
    }

    this.comments.push(newComment);

    this.data.body = '';
}

Inside of saveComment we build up our newComment with the data from our form, our user, and elsewhere. Notably, we're not just setting the id to the length of the comments Array. This could lead to collisions in our key if we deleted the first comment and then tried to add another one. This approach guarantees that we won't have any collisions.

If we head on over to our browser, we can see that our saveComment method is working just like we'd expect.

The Wrap-Up

With our basic CRUD actions in place, our comments system is coming together! But, right now it's a little hard to look at. In the next post, we'll style everything nicely with Tailwind CSS.

If you'd like an overview of the components we built today, you can see them at on our GitHub repo here. As always, feel free to ask me any questions on Twitter. And until next time, happy coding!

Nick Basile

I craft experiences that help people reach their full potential.

Interested in getting my latest content? Join my mailing list.