SPA with Vue.js and Laravel: Pagination, Breadcrumb & Loading Indicator

SPA with Vue.js and Laravel: Pagination, Breadcrumb & Loading Indicator

In this part, we’ll be implementing three small, yet important features in our app.

First, we’ll apply pagination to our topics. Then, we’ll see how to display a beautiful breadcrumb to show users where they are in the forum’s hierarchy. Finally, we’ll see how easy it’s to show a loading indicator when moving between pages.

Pagination

Obviously, we don’t want to display all topics in our forum all at once; we need to break them up into multiple pages using pagination.

To achieve that, we need to do two things. First, we need to modify our topics end point to paginate the data before sending them — that’s on the server side. Then, on the client side, we’ll include the page number in the request and display the pagination buttons.

So in CategoryController replace everything in topics method with this (read the comments for explanation):

public function topics($categoryId)
{
    // The only change here is that we retrieve topics 
    // with paginate() instead of get().
    // Note that the number of items per page is 3
    // Also note that the page number (in the queryString)
    // is automatically detected by Laravel. 
    $topics = Category::findOrFail($categoryId)
        ->topics()
        ->orderBy('created_at', 'desc')
        ->paginate(3);

    // Return an error message if it's empty
    if ($topics->isEmpty()) {
        return response('The provided page exceeds the available number of pages', 404);
    }
    
    // Return the paginated topics
    return $topics;
}

That’s all you have to do on the server side. (Thanks Laravel for making it so easy!)

Now, let’s move to CategoryView to make this pagination work.

Obviously, we’ll display our pagination buttons bellow the topics. So, add this html code under the closing </topic>:

<div class="Pagination">
  <a
    v-if="+page-1 >= 1"
    v-link="{ name: 'category', params: { categoryId: categoryId }, query: { page: +page - 1 }}"
    class="Page-link Page-link--prev">
    Back
  </a>
  <a
    v-if="+page+1 <= lastPage"
    v-link="{ name: 'category', params: { categoryId: categoryId }, query: { page: +page + 1 }}"
    class="Page-link Page-link--next">
    Next
  </a>
</div>

We’re displaying two buttons here — next and back buttons. But before displaying them, we needed to check whether they are valid in the current page. For instance, we don’t want to display the “next” button if we’re already on the last page — that’s what the v-if is for.

Then, in the v-link, we specified the url for each one, which would ultimately be translated into something like this:

/category/{categoryId}?page={pageNumber}.

Next, we need to modify our route transition to include the page number in the request.

route: {
  data (transition) {
    // Save the categoryId for the pagination buttons above
    this.categoryId = +transition.to.params.categoryId;
    this.page = +transition.to.query.page || 1;

    // Include the page number into the query string
    return this.$http.get(`/api/categories/${this.categoryId}/topics`, { page: this.page })
      .then(({data}) => ({
        topics: data.data,
        lastPage: data.last_page
      }));
  }
}

Note how we needed to save the last page number so we can determine if the “next” button should be displayed — remember the v-if above?

Lastly, we need to register those data variables in our component.

data () {
  return {
    topics: [],
    categoryId: 0,
    page: 1,
    lastPage: 1
  }
}

You should end up with something like this:

Pagination

Breadcrumb

I haven’t yet seen any forum without a breadcrumb in it; so why not add one to ours!

In case you don’t know what a breadcrumb is (who doesn’t know that, anyway?), a breadcrumb is a series of links that indicate how you arrived to the current page starting from the home page. Here’s how ours will look like:

Breadcrumb

There are multiple ways we can implement it, but I prefer to push the responsibilities to the server to make it extremely easier on our client.

By pushing responsibilities to the server, I mean we won’t maintain the current state of our breadcrumb component in the client side. Instead, we receive the whole links tree from the server and just display it.

So, let me show you how it’s done.

In Topic.php, add a breadcrumb field to both $visible and $appends, and, of course, the corresponding accessor method — getBreadcrumbAttribute.

protected $visible = ['id', 'title', 'body', 'views', 'time', 'category_id', 'breadcrumb'];
protected $appends = ['time', 'breadcrumb'];

// …

public function getBreadcrumbAttribute()
{
    $category = $this->category;

    return [
        [ 'title' => 'Home', 'link' => '/' ],
        [ 'title' => $category->name, 'link' => "/category/$category->id"],
        [ 'title' => $this->title, 'link' => "/topic/$this->id"]
    ];
}

This means, every time we receive topics from our server, we’ll see the whole links tree included there. And since topics are the deepest level in our app, it would be sufficient to us when we use it in other levels — categories and home.

That’s it for the API.

Now, let’s switch to App.vue and display the breadcrumb component, which we don’t have yet, under the header in App.vue:

<header class="Header">
  <h1 v-link="'/'" class="Header__logo">
    SPA-FORUM <small>with vue.js</small>
  </h1>
</header>

<breadcrumb :breadcrumb="breadcrumb"></breadcrumb>

<!— … —>

<script>
import Breadcrumb from './Breadcrumb.vue';

export default {
  components: { Breadcrumb },

  data () {
    return {
      breadcrumb: []
    }
  }
}
</script>

Now let’s create that component in components/Breadcrumb.vue:

<template>
  <ol class="Breadcrumb">
    <li class="Breadcrumb-item" v-for="item in breadcrumb">
      <a class="Breadcrumb__link" v-link="{ path: item.link }" v-if="!currentPage(item.link)">
        {{ item.title }}
      </a>
      <span class="Breadcrumb__link Breadcrumb__link--inactive" v-else>
        {{ item.title }}
      </span>
    </li>
  </ol>
</template>

<script>
export default {

  props: ['breadcrumb'],

  methods: {
    currentPage (link) {
      return this.$route.path.split('?')[0] == link;
    }
  }
}
</script>

<style lang="stylus">
.Breadcrumb
  list-style: none
  display: flex
  margin: 0
  margin-bottom: 15px
  padding: 0
  padding-left: 20px;
  margin: 25px 0;

.Breadcrumb:before
  content: ' '
  display: block
  width: 3px
  background: #f0f0f0
  margin-right: 8px

.Breadcrumb-item
  &:first-child:before
    display: none
  &:before
    content: '/'
    padding-left: 15px
    padding-right: 15px
    color: gray

.Breadcrumb__link
  color: deepskyblue
  text-decoration: none
  
  &--inactive
    color: gray

</style>

Nothing is so special about this component, we’re just displaying the links. Also, we’re checking whether each one needs to be active (clickable) or not. Obviously, it shouldn’t be active if it’s for the current page.

Now we come to the important part. Since we have centralized the breadcrumb data in the root component — App.vue — we need a way to update its data from other page components.

To do that, we have two options, either by setting the value directly using this.$root.breadcrumb = breadcrumb, or by emitting an event containing that value. For me, I prefer the latter.

Let’s apply that for each category page. But the main question is, when should we update that breadcrumb component’s data? The answer is pretty easy: every time the topics array is updated; remember that’s where we get that breadcrumb data from.

And by saying “every time the topics array is updated”, I mean we should use a watcher! So, in CategoryView, add this watcher for topics:

watch: {
  topics () {
    // Get breadcrumb from any topic object
    // since they all have the same value.
    // What about the first one?
    let breadcrumb = this.topics[0].breadcrumb;
    // Remove the last link in the breadcrumb
    // because it's for the topic and we're
    // here in the category page
    breadcrumb.pop();
    // Lastly, emit that event with the breadcrumb data
    this.$emit('update-breadcrumb', breadcrumb);
  }
}

In order for that to work, we have to listen for this event in the <router-view> component:

<router-view
  class="view"
  keep-alive
  transition
  transition-mode="out-in"
  @update-breadcrumb="updateBreadcrumb"
>

Then add the corresponding method:

methods: {
  updateBreadcrumb (breadcrumb) {
    this.breadcrumb = breadcrumb
  }
}

Now if you check any category page, you’ll see the breadcrumb displayed as expected.

The last thing we need to do in this section is to tell HomeView about our breadcrumb so it can also display it. But we’ll display a different title there, because it makes more sense to display “Categories” instead of “Home”.

For this case, we would need to update the breadcrumb inside the activate transition hook, because we want it to be updated every time we visit that page — not only once, as in ready method. So in HomeView add an activate transition hook under the route object, like this:

route: {
  activate () {
    this.$emit('update-breadcrumb', [{
      title: 'Categories',
      link: '/'
  }]);
},

That’s it! Now you should see “Categories” in the homepage.

Loading indicator

Unlike traditional apps, in SPAs we don’t make a full reload for each page we visit. But we might end up seeing a blank page when it’s loading. So, we usually avoid that by showing a loading indicator — So, let’s add one!

The cool thing is that it’s pretty easy to do, especially with the library.

Let’s install it first:

npm install nprogress --save

Next, let’s tell elixir to mix its styling into our all.css. So, update your gulpfile.js:

elixir(function(mix) {
  mix.browserify('main.js')
    .styles([
      './node_modules/normalize.css/normalize.css',
      './node_modules/nprogress/nprogress.css'
    ]);
});

All you need to know about NProgress is that you need to call two methods on it: NProgress.start() and NProgress.done(). The first one is to start the loading and the other one is to, well, finish it!

But where should we call them? Obviously, we need to start it when the request is sent, and finish it when we receive the response. And this is where I’ll introduce you to HTTP Interceptors.

HTTP interceptors are, simply, what allow us to do any necessary work before the request is sent and after the response is received — and it’s for all calls made in the app.

The one we’re using here is part of the vue-resource library. To use them, write this code before router.start(App, 'app') in main.js.

Vue.http.interceptors.push({

  request (request) {
    // Start the progress bar
    NProgress.start();
    return request;
  },

  response (response) {
    // Finish the progress bar
    NProgress.done();
    return response;
  }
});

And of course don’t forget to import NProgress at the top:

import NProgress from 'nprogress'

That’s it! Now you should see the progress bar when navigating between pages.

The End

With that, this series has come to the end. I know you probably thought there would be more to cover. You're right! But at this point, I don't think spending more time and energy on this series would be very helpful for several reasons:

  1. The workflow started to feel somewhat repetitive at this point. Hence, there won't be any more valuable stuff to cover; you can figure out the rest on your own. (Note that I'm talking here about this specific series, because there are other important things that we haven't covered in this series, like Vuex for instance).
  2. A lot has changed since the beginning of the series. Vue 2.0 has been released. The recommended approaches have changed. My Vue knowledge has improved!
  3. Showing you what else we can add to this app can take forever; and yet without learning so much new things. So, I'd prefer to start publishing other new and more-focused tutorials on Vue that wouldn't be covered in this series if we continued.

In the end, I'd like to thank you all for reading this series and for the awesome feedback I received about it. And I hope I've added something valuable to the community. I would be even more happy to answer any questions you have about this series or about Vue in general.

Vue Laravel
Taha Shashtari

About Taha Shashtari

I'm a freelance web developer. Laravel & VueJS are my main tools these days and I love building stuff using them. I write constantly on this blog to share my knowledge and thoughts on things related to web development... Let's be friends on twitter.