Build a web application with Laravel and Vue – Part 5: Creating a simple Trello clone using Laravel and Vue

In previous chapters of this series, we considered a couple of concepts useful for developing modern web applications with Laravel and Vue.

In this final part, we are going to combine all the concepts from the previous parts into building a Trello clone using Laravel and Vue.

Here is a screen recording of what the application will look like when complete:

Prerequisites

To follow along in this part of the series you must:

  • Have read part all previous parts of the series.
  • Have all the requirements from previous parts of the series.

When you have all the requirements, we can continue.

Setting up for development

We have already discussed setting up your environment in previous parts of this series so in case you need help please read the previous parts. If you have also been following up with the other parts, you should have already set up a Laravel project.

If you have not set up a project, then you should go back to the previous parts and read them as they give a detailed guide on how to set all set up for this part of the series.

You can still read them!

  1. here 1
  2. here 2
  3. here 3
  4. here 4

Creating API routes

In one of the earlier chapters, we spoke about creating RESTful APIs so the techniques mentioned there will be applied here. Let’s create the endpoints for the API of our Trello clone.

In our routes/api.php file, make sure the file contains the following code:

    <?php

    Route::post('login', 'UserController@login');
    Route::post('register', 'UserController@register');

    Route::group(['middleware' => 'auth:api'], function() {
        Route::get('/category/{category}/tasks', 'CategoryController@tasks');    
        Route::resource('/category', 'CategoryController');
        Route::resource('/task', 'TaskController');
    });

? As a good practice, when creating routes, always put more specific routes ahead of less specific ones. For instance, in the code above the /category/{category}/tasks route is above the less specific /category route.

In the code above, we defined our API routes. Putting the definitions in the routes/api.php file will tell Laravel that the routes are API routes. Laravel will prefix the routes with a /api in the URL to differentiate these routes from web routes.

Also in the Route group above, we added a middleware auth:api, this makes sure that any calls to the routes in that group must be authenticated.

A thing to note is, using the resource method on the Route class helps us create some additional routes under the hood. Here is a summary of all the routes available when we added the code above to the file:

Routes for the category resource

MethodURIFunction
GET/api/categoryTo list all the available categories
POST/api/categoryTo create a new category resource
DELETE/api/category/{category_id}To delete a particular category resource
GET/api/category/{category_id}To fetch a particular category resource
GET/api/category/{category}/tasksTo fetch all tasks for particular category
PUT/api/category/{category_id}To update a particular category resource

Routes for the task resource

MethodURIFunction
GET/api/taskTo list all the available tasks
POST/api/taskTo create a new task resource
DELETE/api/task/{task_id}To delete a particular task
GET/api/task/{task_id}To fetch a particular task resource
PUT/api/task/{task_id}To update a particular task resource

Routes for the user resource

MethodURIFunction
POST/api/registerCreate a new user
POST/api/loginLog an existing user in

? To see the full route list, run the following command: $ php artisan route:list.

Now that we have a clear understanding of our routes, let’s see how the controllers will work.

Creating the controller logic

We are going to take a deeper look at the implementation of our different controllers now.

User controller

Since we already created and fully implemented this in the second part of the series, we can skip that and move on to the next controller.

Category controller

Next, open the CategoryController and replace the contents with the following code:

    <?php

    namespace App\Http\Controllers;

    use App\Category;
    use Illuminate\Http\Request;

    class CategoryController extends Controller
    {
        public function index()
        {
            return response()->json(Category::all()->toArray());
        }

        public function store(Request $request)
        {
            $category = Category::create($request->only('name'));

            return response()->json([
                'status' => (bool) $category,
                'message'=> $category ? 'Category Created' : 'Error Creating Category'
            ]);
        }

        public function show(Category $category)
        {
            return response()->json($category);
        }

        public function tasks(Category $category)
        {
            return response()->json($category->tasks()->orderBy('order')->get());
        }

        public function update(Request $request, Category $category)
        {
            $status = $category->update($request->only('name'));

            return response()->json([
                'status' => $status,
                'message' => $status ? 'Category Updated!' : 'Error Updating Category'      
            ]);
        }

        public function destroy(Category $category)
        {
            $status  = $category->delete();

            return response()->json([
                'status' => $status,
                'message' => $status ? 'Category Deleted' : 'Error Deleting Category'
            ]);
        }

    }

The functions in the controller above handle the basic CRUD operations for the resource. The tasks methods return tasks associated with a category.

Task controller

Next, open the TaskController. In this controller, we will manage tasks. A task is given an order value and is linked to a category. Replace the contents with the following code:

    <?php

    namespace App\Http\Controllers;

    use App\Task;
    use Illuminate\Http\Request;

    class TaskController extends Controller
    {
        public function index()
        {
            return response()->json(Task::all()->toArray());
        }

        public function store(Request $request)
        {
            $task = Task::create([
                'name' => $request->name,
                'category_id' => $request->category_id,
                'user_id' => $request->user_id,
                'order' => $request->order
            ]);

            return response()->json([
                'status' => (bool) $task,
                'data'   => $task,
                'message' => $task ? 'Task Created!' : 'Error Creating Task'
            ]);
        }

        public function show(Task $task)
        {   
           return response()->json($task); 
        }

        public function update(Request $request, Task $task)
        {
            $status = $task->update(
                $request->only(['name', 'category_id', 'user_id', 'order'])
            );

            return response()->json([
                'status' => $status,
                'message' => $status ? 'Task Updated!' : 'Error Updating Task'
            ]);
        }

        public function destroy(Task $task)
        {
            $status = $task->delete();

            return response()->json([
                'status' => $status,
                'message' => $status ? 'Task Deleted!' : 'Error Deleting Task'
            ]);
        }

    }

That’s all for the controllers. Since we have already created the models in a previous chapter, let’s move on to creating the frontend.

Building the frontend

Since we are done building the backend, let’s make the frontend using VueJS. To work with Vue, we will need the Vue and Vue router packages we installed in a previous chapter. We will also need the [vuedraggable](https://github.com/SortableJS/Vue.Draggable) package. To install it, run the command below:

    $ npm install vuedraggable --save

Creating the Vue router routes

Since we are building a Single Page App, we are going to set up our vue-router to handle switching between the different pages of our application. Open the resources/assets/js/app.js file and replace the contents with the following code:

    import Vue          from 'vue'
    import VueRouter    from 'vue-router'

    Vue.use(VueRouter)

    import App          from './views/App'
    import Dashboard    from './views/Board'
    import Login        from './views/Login'
    import Register     from './views/Register'
    import Home         from './views/Welcome'

    const router = new VueRouter({
        mode: 'history',
        routes: [
            {
                path: '/',
                name: 'home',
                component: Home
            },
            {
                path: '/login',
                name: 'login',
                component: Login,
            },
            {
                path: '/register',
                name: 'register',
                component: Register,
            },
            {
                path: '/board',
                name: 'board',
                component: Dashboard,
            },
        ],
    });

    const app = new Vue({
        el: '#app',
        components: { App },
        router,
    });

Next, open the routes/web.php file and replace the contents with the code below:

    <?php

    Route::get('/{any}', 'SinglePageController@index')->where('any', '.*');

This will route incoming traffic to the index method of our SinglePageController which we created in the previous chapter.

Dealing with authentication

Since our API is secure we’d need access tokens to make calls to it. Tokens are generated and issued when we successfully log in or register. We are going to use localStorage to hold the token generated by our application so we can very easily get it when we need to make API calls.

 Although this is out of the scope of the article it may be worth knowing that contents in local storage are readable from the browser so you might want to make sure your tokens are short-lived and refreshed often.

Let’s set up register component. Create the file resources/assets/js/views/Register.vue and add the following for the template:

    <template>
        <div class="container">
            <div class="row justify-content-center">
                <div class="col-md-8">
                    <div class="card card-default">
                        <div class="card-header">Register</div>

                        <div class="card-body">
                            <form method="POST" action="/register">
                                <div class="form-group row">
                                    <label for="name" class="col-md-4 col-form-label text-md-right">Name</label>

                                    <div class="col-md-6">
                                        <input id="name" type="text" class="form-control" v-model="name" required autofocus>
                                    </div>
                                </div>

                                <div class="form-group row">
                                    <label for="email" class="col-md-4 col-form-label text-md-right">E-Mail Address</label>

                                    <div class="col-md-6">
                                        <input id="email" type="email" class="form-control" v-model="email" required>
                                    </div>
                                </div>

                                <div class="form-group row">
                                    <label for="password" class="col-md-4 col-form-label text-md-right">Password</label>

                                    <div class="col-md-6">
                                        <input id="password" type="password" class="form-control" v-model="password" required>
                                    </div>
                                </div>

                                <div class="form-group row">
                                    <label for="password-confirm" class="col-md-4 col-form-label text-md-right">Confirm Password</label>

                                    <div class="col-md-6">
                                        <input id="password-confirm" type="password" class="form-control" v-model="password_confirmation" required>
                                    </div>
                                </div>

                                <div class="form-group row mb-0">
                                    <div class="col-md-6 offset-md-4">
                                        <button type="submit" class="btn btn-primary" @click="handleSubmit">
                                            Register
                                        </button>
                                    </div>
                                </div>
                            </form>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </template>

Then for the script, add the following in the same file below the closing template tag:

    <script>
        export default {
            data(){
                return {
                    name : "",
                    email : "",
                    password : "",
                    password_confirmation : ""
                }
            },
            methods : {
                handleSubmit(e) {
                    e.preventDefault()

                    if (this.password === this.password_confirmation && this.password.length > 0)
                    {
                        axios.post('api/register', {
                            name: this.name,
                            email: this.email,
                            password: this.password,
                            c_password : this.password_confirmation
                          })
                          .then(response => {
                            localStorage.setItem('user',response.data.success.name)
                            localStorage.setItem('jwt',response.data.success.token)

                            if (localStorage.getItem('jwt') != null){
                                this.$router.go('/board')
                            }
                          })
                          .catch(error => {
                            console.error(error);
                          });
                    } else {
                        this.password = ""
                        this.passwordConfirm = ""

                        return alert('Passwords do not match')
                    }
                }
            },
            beforeRouteEnter (to, from, next) { 
                if (localStorage.getItem('jwt')) {
                    return next('board');
                }

                next();
            }
        }
    </script>

In the code above, we have a handleSubmit method that is called when a user submits the registration form. It sends all the form data to the API, takes the response and saves the jwt to localStorage.

We also have a beforeRouterEnter method which is called by the vue-router before loading a component. In this callback, we check if the user is already logged in and redirect to the application’s board if the user is.

The login component is setup in a similar manner. Create the file resources/assets/js/views/Login.vue and add the following for the template:

    <template>
        <div class="container">
            <div class="row justify-content-center">
                <div class="col-md-8">
                    <div class="card card-default">
                        <div class="card-header">Login</div>

                        <div class="card-body">
                            <form method="POST" action="/login">
                                <div class="form-group row">
                                    <label for="email" class="col-sm-4 col-form-label text-md-right">E-Mail Address</label>

                                    <div class="col-md-6">
                                        <input id="email" type="email" class="form-control" v-model="email" required autofocus>
                                    </div>
                                </div>

                                <div class="form-group row">
                                    <label for="password" class="col-md-4 col-form-label text-md-right">Password</label>

                                    <div class="col-md-6">
                                        <input id="password" type="password" class="form-control" v-model="password" required>
                                    </div>
                                </div>

                                <div class="form-group row mb-0">
                                    <div class="col-md-8 offset-md-4">
                                        <button type="submit" class="btn btn-primary" @click="handleSubmit">
                                            Login
                                        </button>
                                    </div>
                                </div>
                            </form>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </template>

And for the script, add the following code to the file below the closing template tag:

    <script>
        export default {
            data(){
                return {
                    email : "",
                    password : ""
                }
            },
            methods : {
                handleSubmit(e){
                    e.preventDefault()

                    if (this.password.length > 0) {
                        axios.post('api/login', {
                            email: this.email,
                            password: this.password
                          })
                          .then(response => {
                            localStorage.setItem('user',response.data.success.name)
                            localStorage.setItem('jwt',response.data.success.token)

                            if (localStorage.getItem('jwt') != null){
                                this.$router.go('/board')
                            }
                          })
                          .catch(function (error) {
                            console.error(error);
                          });
                    }
                }
            },
            beforeRouteEnter (to, from, next) { 
                if (localStorage.getItem('jwt')) {
                    return next('board');
                }

                next();
            }
        }
    </script>

That’s all for the Login component.

We need to make a little modification to our application wrapper component. Open the file resources/assets/js/views/App.vue file and update the file with the following code in the template section:

    [...]

    <!-- Right Side Of Navbar -->
    <ul class="navbar-nav ml-auto">
        <!-- Authentication Links -->
        <router-link :to="{ name: 'login' }" class="nav-link" v-if="!isLoggedIn">Login</router-link>
        <router-link :to="{ name: 'register' }" class="nav-link" v-if="!isLoggedIn">Register</router-link>
        <li class="nav-link" v-if="isLoggedIn"> Hi, {{name}}</li>
        <router-link :to="{ name: 'board' }" class="nav-link" v-if="isLoggedIn">Board</router-link>
    </ul>

    [...]

Also, replace the contents of the script tag in the same file with the following:

    export default {
        data(){
            return {
                isLoggedIn : null,
                name : null
            }
        },
        mounted(){
            this.isLoggedIn = localStorage.getItem('jwt')
            this.name = localStorage.getItem('user')
        }
    }

In the code above, we do a check to see if the user is logged in or not and then use this knowledge to can hide or show route links.

Making secure API calls

Next, let’s create the main application board and consume the meat of the API from there. Create a resources/assets/js/views/Board.vue file add the following code to the file:

    <template>
        <div class="container">
            <div class="row justify-content-center">
                <draggable element="div" class="col-md-12" v-model="categories" :options="dragOptions">
                    <transition-group class="row">
                        <div class="col-md-4" v-for="element,index in categories" :key="element.id">
                            <div class="card">
                                <div class="card-header">
                                    <h4 class="card-title">{{element.name}}</h4>
                                </div>
                                <div class="card-body card-body-dark">
                                    <draggable :options="dragOptions" element="div" @end="changeOrder" v-model="element.tasks">
                                        <transition-group :id="element.id">
                                            <div v-for="task,index in element.tasks" :key="task.category_id+','+task.order" class="transit-1" :id="task.id">
                                                <div class="small-card">
                                                    <textarea v-if="task === editingTask" class="text-input" @keyup.enter="endEditing(task)" @blur="endEditing(task)" v-model="task.name"></textarea>
                                                    <label for="checkbox" v-if="task !== editingTask" @dblclick="editTask(task)">{{ task.name }}</label>
                                                </div>
                                            </div>
                                        </transition-group>
                                    </draggable>
                                    <div class="small-card">
                                        <h5 class="text-center" @click="addNew(index)">Add new card</h5>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </transition-group>
                </draggable>
            </div>
        </div>
    </template>

In the template above we have implemented the [vue-draggable](https://github.com/SortableJS/Vue.Draggable) component, we installed earlier. This gives us a draggable div that we can use to mimic how Trello cards can be moved from one board to another. In the draggable tag we passed some options which we will define in the script section of the component soon.

To ensure we can drag across multiple lists using vue draggable, we had to bind our categories attribute to the parent draggable component. The most important part is binding the element.tasks to the child draggable component as a prop using v-model. If we fail to bind this, we would not be able to move items across the various categories we have.

We also define a method to be called when the dragging of an item is done (@end), when we click to edit an item or when we click the Add New Card.

For our style add the following after the closing template tag:

    <style scoped>
    .card {
        border:0;
        border-radius: 0.5rem;
    }
    .transit-1 {
        transition: all 1s;
    }
    .small-card {
        padding: 1rem;
        background: #f5f8fa;
        margin-bottom: 5px;
        border-radius: .25rem;
    }
    .card-body-dark{
        background-color: #ccc;
    }
    textarea {
        overflow: visible;
        outline: 1px dashed black;
        border: 0;
        padding: 6px 0 2px 8px;
        width: 100%;
        height: 100%;
        resize: none;
    }
    </style>

Right after the code above, add the following code:

    <script>
        import draggable from 'vuedraggable'
        export default {
            components: {
                draggable
            },
            data(){
                return {
                    categories : [],
                    editingTask : null
                }
            },
            methods : {
                addNew(id) {
                },
                loadTasks() {
                },
                changeOrder(data){
                },
                endEditing(task) {
                },
                editTask(task){
                    this.editingTask = task
                }
            },
            mounted(){
            },
            computed: {
                dragOptions () {
                  return  {
                    animation: 1,
                    group: 'description',
                    ghostClass: 'ghost'
                  };
                },
            },
            beforeRouteEnter (to, from, next) { 
                if ( ! localStorage.getItem('jwt')) {
                    return next('login')
                }

                next()
            }
    }
    </script>

Loading our categories

Let’s load our categories as we mount the Board component. Update the mounted method of the same file to have the following:

    mounted() {
        let token = localStorage.getItem('jwt')

        axios.defaults.headers.common['Content-Type'] = 'application/json'
        axios.defaults.headers.common['Authorization'] = 'Bearer ' + token

        axios.get('api/category').then(response => {
            response.data.forEach((data) => {
                this.categories.push({
                    id : data.id,
                    name : data.name,
                    tasks : []
                })
            })
            this.loadTasks()
        })
    },

In the code above, we set up axios. This is very important because vue will call the mounted method first before the page loads, so it is a convenient way to actually load data we need to use on our page.

? We set up some default axios headers so we no longer need to pass the headers for each call we make.

Loading our tasks

Now we can add the logic to load the tasks from a category. In the methods object of the Board component, update the loadTasks method to the following code:

    [...]

    loadTasks() {
      this.categories.map(category => {
          axios.get(`api/category/${category.id}/tasks`).then(response => {
              category.tasks = response.data
          })
      })
    },

    [...]

Adding new tasks

Let’s add the logic to add new tasks. In the methods object of the Board component, update the addNew method to the following:

    [...]

    addNew(id) {
        let user_id = 1
        let name = "New task"
        let category_id = this.categories[id].id
        let order = this.categories[id].tasks.length

        axios.post('api/task', {user_id, name, order, category_id}).then(response => {
            this.categories[id].tasks.push(response.data.data)
        })
    },

    [...]

When the addNew method is called the id of the category is passed in, which helps us determine where the new task should be added. We create the task for that category and pass in a dummy text as a placeholder so the user can see it come up.

Editing tasks

We will now add the logic to edit tasks. In the methods object of the Board component, update the endEditing method to the following:

    [...]

    endEditing(task) {
        this.editingTask = null

        axios.patch(`api/task/${task.id}`, {name: task.name}).then(response => {
            // You can do anything you wan't here.
        })
    },

    [...]

When a task is edited, we pass it to the endEditing method which sends it over to the API.

Reordering tasks

Now we can get the logic to reorder tasks. In the methods object of the Board component, update the changeOrder method to the following:

    [...]

    changeOrder(data) {
        let toTask = data.to
        let fromTask = data.from
        let task_id = data.item.id
        let category_id = fromTask.id == toTask.id ? null : toTask.id
        let order = data.newIndex == data.oldIndex ? false : data.newIndex

        if (order !== false) {
            axios.patch(`api/task/${task_id}`, {order, category_id}).then(response => {
                // Do anything you want here
            });
        }
    },

    [...]

Draggable returns an object when you drop an element you dragged. The returned object contains information of where the element was moved from and where it was dropped. We use this object to determine which category a task was moved from.

? If you go over the draggable component again, you’d notice we bound :id when we were rendering categories. This is the same id referenced above.

Build the application

The next thing we need to do is build the assets. Run the command below to build the application:

    $ npm run prod

? Using prod will optimize the build. Recommended especially when you want to build for production. The other value available here is dev which is used during the development process

When the build is complete, we can now run the application:

    $ php artisan serve

Conclusion

In this series, we have seen how to build a simple Trello clone and in the process explained some key concepts you need to know when building modern web applications using Laravel and Vue.

Pin It on Pinterest