Create a live commenting system with Adonis.js and Vue.js
You will need Node and MySQL set up on your machine.
Introduction
In this tutorial, we’ll see how to build a live commenting system using Adonis.js on the backend, Vue.js to dynamise our views and Pusher to add the realtime commenting feature in our app. As users submit comments, they will be added and viewed instantly.
Pages that force you to reload them to view new comments can be annoying for users, especially since they don’t even know if their even is reply to your comment yet. This poor user experience can cause users to abandon your site.
Demo
Here is the final result of our app:
Prerequisites
In order to follow this tutorial, knowledge of Javascript and Node.js is required. You should also have the following installed on your machine:
Set up our Adonis.js project
Before any step we should install Adonis.js on our local machine if this is not done yet. Open your terminal and run this command in order to do so:
# if you don't have Adonis CLI installed on your machine.
npm install -g @adonisjs/cli
# Create a new adonis app and move into the app directory
$ adonis new adonis-comments-pusher && cd adonis-comments-pusher
Now start the server and test if everything is working fine:
adonis serve --dev
2018-09-23T12:25:30.326Z - info: serving app on http://127.0.0.1:3333
If the steps above were successful, open your browser and make a request to : http://127.0.0.1:3333.
You should see the following:
Set up Pusher and install other dependencies
Head over to Pusher and create an account or sign in if you already have a account.
Next, create a new Pusher app instance. This registration provides credentials which can be used to communicate with the created Pusher instance. Copy the App ID, Key, Secret, and Cluster from the App Keys section and put them in the .env
file located at you project root:
//.env
PUSHER_APP_KEY=<APP_KEY>
PUSHER_APP_SECRET=<APP_SECRET>
PUSHER_APP_ID=<APP_ID>
PUSHER_APP_CLUSTER=<APP_CLUSTER>
We’ll use these keys further in this tutorial to link Pusher with our Adonis project.
Next, we need to install the Pusher SDK as well as other dependencies we’ll need to build our app.
We won’t use the Pusher SDK directly but instead use a Pusher provider for Adonis. This provider enables us to use easily the Pusher SDK with the Adonis.js ecosystem.
But we should first install the Pusher SDK by running this command:
#if you want to use npm
npm install pusher
#or if you prefer Yarn
yarn add pusher
Now, you can install the Pusher provider for Adonis with this command:
#if you want to use npm
npm install adonis-pusher
#or if you prefer Yarn
yarn add adonis-pusher
You will need to add the provider to AdonisJS at start/app.js
:
const providers = [
...
'adonis-pusher/providers/Pusher'
]
Last, let’s install other dependencies that we’ll use to build our app.
Run this command in your terminal:
#if you want to use npm
npm install vue vuex axios laravel-mix pusher-js mysql cross-env
#or if you prefer Yarn
yarn add vue vuex axios laravel-mix pusher-js mysql cross-env
Dependencies we will use:
vue
andvuex
respectively to build the frontend of our app and manage our data store,axios
to make HTTP requests to our API endpoints- laravel-mix to provide a clean, fluent API for defining basic webpack build steps
pusher-js
to listen to events emitted from our servermysql
, Node.js driver for MySQL to set up our database as this app will use MySQL for storagecross-env
to run scripts that set and use environment variables across platforms
Set up our build workflow
We’ll use laravel-mix to build and compile our application assets in a fluent way. But first we must tell our app to use it for that purpose. Open your package.json
file and paste the following in the scripts section:
"asset-dev": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"asset-watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"asset-watch-poll": "npm run watch -- --watch-poll",
"asset-hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
"asset-prod": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
After that create a webpack.mix.js
file at the root of your project and paste this code:
const mix = require('laravel-mix');
mix.setPublicPath('public');
/*
|--------------------------------------------------------------------------
| Mix Asset Management
|--------------------------------------------------------------------------
|
| Mix provides a clean, fluent API for defining some Webpack build steps
| for your Laravel application. By default, we are compiling the Sass
| file for your application, as well as bundling up your JS files.
|
*/
mix.js('resources/assets/js/app.js', 'public/js')
The code above builds, compiles and bundles all our javascript code into a single js file created automatically in public/js
directory.
Now create this file assets/js/bootstrap.js
and paste this code inside:
window._ = require('lodash');
window.axios = require('axios');
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';
window.axios.defaults.headers.common.crossDomain = true;
window.axios.defaults.baseURL = '/api';
let token = document.head.querySelector('meta[name="csrf-token"]');
if (token) {
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
} else {
console.error('CSRF token not found: https://adonisjs.com/docs/4.1/csrf');
}
window.Pusher = require('pusher-js');
You will notice we require dependencies to build our app. We also globally registered some headers to the axios library in order to handle some security issues and to tackle in a proper way our API endpoints. These headers enable respectively ajax request, define Content-Type
for our post requests, CORS and register the CSRF token.
Next, create this file: assets/js/app.js
and paste the following inside:
require('./bootstrap')
When we import our bootstrap.js
file , laravel-mix will compile our app.js
file.
Our app is now ready to use laravel-mix for building and compiling our assets. By running this command: npm run asset-dev
you should see a public/js/app.js
file after the build process. Great!
Build our comment model and migration
First we need to set up our database, we’ll use a MySQL database for storage in this tutorial. Open your .env
file and update the database section with your own identifiers:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=your_database_user
DB_PASSWORD=your_dtabase_password
DB_DATABASE=your_database_name
Next, open your terminal and run the command below to generate our Comment
model as well as its corresponding controller and migration file which will be used to build the schema for our comments table:
adonis make:model Comment -mc
Inside your product migration file, copy and paste this code:
//../database/migrations/*_comment_schema.js
'use strict'
const Schema = use('Schema')
class CommentSchema extends Schema {
up() {
this.create('comments', (table) => {
table.increments()
table.string('content')
table.integer('author')
table.timestamps()
})
}
down() {
this.drop('comments')
}
}
module.exports = CommentSchema
Our comment schema is pretty straightforward.
You can see we defined our comments table fields as:
- content: to hold the comment text
- author: to contain the author’s name
The increments()
will create an id
field with Auto Increment
and set it as Primary key
. The timestamps()
will create the created_at
and updated_at
fields respectively.
Now if your run this command: adonis migration:run
in your terminal it will create a comments table in your database.
Define routes and create the controller
In this part of the tutorial, we’ll create our routes and define controller functions responsible for handling our HTTP requests.
We’ll create three basic routes for our application, one for rendering our app view, one for fetching comments from the database and the last one for storing comments into the database.
Go to the start/routes.js
file and replace the content with:
const Route = use('Route')
Route.get('/', 'CommentController.index')
Route.group(() => {
Route.get('/comments', 'CommentController.fetchComments')
Route.post('/comments', 'CommentController.store')
}).prefix('api')
This block pulls in Route
service provider.
Routes defining in Adonis is similar to the Laravel methodology and you should not have any problems if you have worked with Laravel. We prefixed two of our routes with api
to help remind us that they are api endpoints.
Next let’s create our controller functions. Open your CommentController.js
file and paste the following:
'use strict'
const Comment = use('App/Models/Comment')
const Event = use('Event')
class CommentController {
async index({view}) {
return view.render('comment')
}
async fetchComments({request, response}) {
let comments = await Comment.all()
return response.json(comments)
}
async store({request, response}) {
try {
let comment = await Comment.create(request.all())
Event.fire('new::comment', comment.toJSON())
return response.json("ok")
} catch (e) {
console.log(e)
}
}
}
module.exports = CommentController
The first lines import Event
service provider and the Comment
model.
You can notice three functions in the code above:
index
renders thecomment.edge
file(that we’ll create later in this tutorial) in theresources/views
directory (which is where views are stored in Adonis).fetchComments
fetches comments from our database and returns them in a JSON formatstore
creates a newComment
instance with the request queries. We also fire an event namednew::comment
with the new instance in a JSON format. We can listen to this event and manipulate the data it carries.
Emit event with Pusher channels
Create a filename event.js
in the start
directory. In this file we’ll create an event which will be fired every time we need to send a message via Pusher channels, and as it happens a posted comment via Pusher channels.
//events.js
const Pusher = use('Pusher')
const Event = use('Event');
const Env = use('Env');
// set up Pusher
let pusher = new Pusher({
appId: Env.get('PUSHER_APP_ID'),
key: Env.get('PUSHER_APP_KEY'),
secret: Env.get('PUSHER_APP_SECRET'),
cluster: Env.get('PUSHER_APP_CLUSTER'),
encrypted: false
});
//fire new event
Event.when('new::comment', async (comment) => {
pusher.trigger('comment-channel', 'new-comment', {
comment
})
});
We need to pull in the Event
, Pusher
(using the adonis-pusher package we installed earlier) and Env
service providers.
Next, we registered a listener for the new::comment
event, after which we initialize and configure Pusher. This event was registered in the CommentController.store
function we created above to handle comment creation.
When we are done with the pusher configuration, we trigger a new-comment
event on the comment-channel
with the trigger
method.
Set up Vuex store
We’ll be using the Vuex library to centralize our data and control the way it is mutated throughout our application.
Create our state
Vuex state is a single object that contains all our application data. So let’s create ../resources/js/store/state.js
and paste this code inside:
let state = {
comments: []
}
export default state
The comments
key is an array responsible to store our database comments.
Create our getters
With help of getters we can compute derived based on our data store state. Create ../resources/js/store/getters.js
and paste this code inside
let getters = {
comments: state => {
return state.comments
}
}
export default getters
Create our mutations
Mutations allow us to perform some changes on our data. Create ../resources/js/store/mutations.js
and paste this piece of code inside:
let mutations = {
GET_COMMENTS(state, comments) {
state.comments = comments
},
ADD_COMMENT(state, comment) {
state.comments = [...state.comments, comment]
}
}
export default mutations
Our mutations
object has 2 functions:
GET_COMMENTS
is responsible for getting our comments data from a database or webserver.ADD_COMMENT
is responsible for adding a new comment to our comments array using the ES6 spread operator.
Create our actions
Vuex actions allow us to perform asynchronous operations over our data. Create the file ../resources/js/store/actions.js
and paste the following code:
let actions = {
ADD_COMMENT({commit}, comment) {
return new Promise((resolve, reject) => {
axios.post(`/comments`, comment)
.then(response => {
resolve(response)
}).catch(err => {
reject(err)
})
})
},
GET_COMMENTS({commit}) {
axios.get('/comments')
.then(res => {
{
commit('GET_COMMENTS', res.data)
}
})
.catch(err => {
console.log(err)
})
}
}
export default actions
We have defined two actions and each of them is responsible for a single operation, either comments post or comments fetch. They both perform asynchronous calls to our API routes.
-
ADD_COMMENT
sends a post request to our/api/comments
with the new comment to create and returns a new promise (later in this tutorial we’ll handle the returned promise). This action is dispatched whenever a user submits a comment. -
GET_COMMENTS
makes a get request to ourapi/comments
endpoint to get our database comments and commits the request result withGET_COMMENTS
mutation.
Set up our store with Vue
Create the file ../resources/assets/js/store/index.js
and paste this code inside:
import Vue from 'vue'
import Vuex from 'vuex'
import actions from './actions'
import mutations from './mutations'
import getters from './getters'
import state from "./state";
Vue.use(Vuex);
export default new Vuex.Store({
state,
mutations,
getters,
actions
})
Next, we will export our store and add it to the Vue instance.
Add this code to your ../resouces/js/app.js
file.
require('./bootstrap')
window.Vue = require('vue');
import store from './store/index'
Vue.component('comment', require('./components/Comment'));
Vue.component('comments', require('./components/Comments'))
Vue.component('new-comment', require('./components/NewComment'))
const app = new Vue({
el: '#app',
store
});
The code above globally registers three Vue components, Comment.vue
,Comments.vue
and NewComment.vue
that we’ll build in the next part of this tutorial.
Building Vue components
We’ll build three Vue components for our app, the Comment.vue
component, the Comments.vue
and the NewComment.vue
component, each of them responsible for a single functionality.
Create the Comment.vue component
The Comment.vue
component is responsible for encapsulating details about a single comment instance from the database and rendering it in a proper and styled way.
Paste the following inside your Comment.vue
component.
//../resources/assets/js/components/Comment.vue
<template>
<li class="comment-wrapper animate slideInLeft ">
<div class="profile">
<img :src="avatar" alt=""></div>
<div class="msg has-shadow">
<div class="msg-body"><p class="name">{{comment.author}} <span class="date">{{posted_at}}</span></p>
<p class="content">{{comment.content}}</p></div>
</div>
</li>
</template>
<script>
export default {
name: "Comment",
props: ['comment'],
computed: {
posted_at() {
return moment(this.comment.created_at).format('MMMM Do YYYY')
},
avatar() {
return `https://api.adorable.io/avatars/48/${this.comment.author}@adorable.io.png`
}
}
}
</script>
<style lang="scss" scoped>
.comment-wrapper {
list-style: none;
text-align: left;
overflow: hidden;
margin-bottom: 2em;
padding: .4em;
.profile {
width: 80px;
float: left;
}
.msg-body {
padding: .8em;
color: #666;
line-height: 1.5;
}
.msg {
width: 86%;
float: left;
background-color: #fff;
border-radius: 0 5px 5px 5px;
position: relative;
&::after {
content: " ";
position: absolute;
left: -13px;
top: 0;
border: 14px solid transparent;
border-top-color: #fff;
}
}
.date {
float: right;
}
.name {
margin: 0;
color: #999;
font-weight: 700;
font-size: .8em;
}
p:last-child {
margin-top: .6em;
margin-bottom: 0;
}
.
}
</style>
Our Comment.vue
component takes a comment
property whose details we simply render in the component body. We also defined two computed
properties, posted_at
to parse the Moment.js library with the comment
posted date, and avatar
to generate an avatar for the comment author using this API.
In the style
block we’ve defined some styles to our comment component in order to make things look more beautiful.
Create the Comments.vue component
This component will render comment items from the database.
Create your Comments.vue
component and paste this code inside:
../resources/assets/js/components/Comments.vue
<template>
<div class="container">
<ul class="comment-list">
<Comment :key="comment.id" v-for="comment in comments" :comment="comment"></Comment>
</ul>
</div>
</template>
<script>
import {mapGetters} from 'vuex'
import Comment from './Comment'
export default {
name: "Comments",
components: {Comment},
mounted() {
this.$store.dispatch('GET_COMMENTS')
//use your own credentials you get from Pusher
let pusher = new Pusher(YOUR_PUSHER_APP_ID, {
cluster: YOUR_PUSHER_CLUSTER,
encrypted: false
});
//Subscribe to the channel we specified in our Adonis Application
let channel = pusher.subscribe('comment-channel')
channel.bind('new-comment', (data) => {
this.$store.commit('ADD_COMMENT', data.comment)
})
},
computed: {
...mapGetters([
'comments'
])
}
}
</script>
<style scoped>
.comment-list {
padding: 1em 0;
margin-bottom: 15px;
}
</style>
In the template
section of this code, we loop through our comments array and render for each loop iteration a Comment.vue
component imported with the current comment iterated as a property.
In the mounted
hook function we dispatched the GET_COMMENTS
action. The action defined above sends a get request to our database to fetch posted comments. Then, we initialized a Pusher instance using the credentials obtained earlier when creating our Pusher app. Next, we subscribed to the comment-channel
and listened to the new-comment
event in order to commit the ADD_COMMENT
mutation with the new comment pulled in by the event.
We also used the Vuex helper function …mapGetters()
to access our comments state as computed
property. In this component we also defined some styles to beautify our interface in the style
block.
Create the New-Comment.vue component
Our third component is responsible for displaying a form to our users for comment posting. It should also send a request to our database when a user submits his comment. Let’s create the New-Comment.vue
component, copy and paste this code inside:
<template>
<div id="commentForm" class="box has-shadow has-background-white">
<form @keyup.enter="postComment">
<div class="field has-margin-top">
<div class="field has-margin-top">
<label class="label">Your name</label>
<div class="control">
<input type="text" placeholder="Your name" class="input is-medium" v-model="comment.author">
</div>
</div>
<div class="field has-margin-top">
<label class="label">Your comment</label>
<div class="control">
<textarea
style="height:100px;"
name="comment"
class="input is-medium" autocomplete="true" v-model="comment.content"
placeholder="lorem ipsum"></textarea>
</div>
</div>
<div class="control has-margin-top">
<button style="background-color: #47b784" :class="{'is-loading': submit}"
class="button has-shadow is-medium has-text-white"
:disabled="!isValid"
@click.prevent="postComment"
type="submit"> Submit
</button>
</div>
</div>
</form>
<br>
</div>
</template>
<script>
export default {
name: "NewComment",
data() {
return {
submit: false,
comment: {
content: '',
author: '',
}
}
},
methods: {
postComment() {
this.submit = true;
this.$store.dispatch('ADD_COMMENT', this.comment)
.then(response => {
this.submit = false;
if (response.data === 'ok')
console.log('success')
}).catch(err => {
this.submit = false
})
},
},
computed: {
isValid() {
return this.comment.content !== '' && this.comment.author !== ''
}
}
}
</script>
<style scoped>
.has-margin-top {
margin-top: 15px;
}
</style>
We bind our comment
data to our comment content and author name fields using the Vue.js v-model
directive. We handled the form submission with the postComment
function inside which we dispatch the ADD_COMMENT
mutation with the comment data entered by the user. We also defined isValid
as a computed property that we use to disable the submit button if the two required fields are empty.
Finalize the app
Now, let’s create our comment.edge
file which contains our three Vue.js components. Run this command: adonis make:view comment
to create the file. Then paste this code inside:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Realtime search with Adonis and Pusher</title>
<meta name="csrf-token" content="{{csrfToken}}">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.1/css/bulma.min.css"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/3.5.2/animate.min.css"/>
<style>
html {
background: radial-gradient(ellipse at center, #fff 0, #ededfd 100%);
}
#app {
width: 60%;
margin: 4rem auto;
}
.container {
margin: 0 auto;
position: relative;
width: unset;
}
.question-wrapper {
text-align: center;
}
.has-shadow {
box-shadow: 0 4px 8px -2px rgba(0, 0, 0, 0.05) !important;
}
</style>
</head>
<body>
<div id="app">
<div class="container">
<div class="question-wrapper">
<img width="200" src="{{ assetsUrl('images/adonuxt.png') }}" alt="">
<h5 class="is-size-2" style="color: #220052;">
What do you think about <span style="color: #47b784;">Adonuxt</span>?</h5>
<br>
<a href="#commentForm" class="button is-medium has-shadow has-text-white" style="background-color: #47b784">Comment</a>
</div>
<br><br>
<comments></comments>
<new-comment></new-comment>
</div>
</div>
{{ script('js/app.js') }}
</body>
</html>
We are almost done! Now open your terminal and run npm run asset-dev
to build your app. This can take a few seconds. After this step, run adonis serve --dev
and open your browser to localhost:3333
to see your app working. Try posting a new comment! You should see your comment added in realtime 😎.
Conclusion
In this tutorial, we have covered how to create a live commenting system using Adonis.js, Vue.js and Pusher. You can get the full source code here.
7 December 2018
by Ethiel Adiassa