Create Infinite Scroll with Filters in Laravel Using Vuejs


I believe everybody is familiar with YouTube's and Facebooks's Infinite scroll. Today In this tutorial I will show you how to easily achieve Infinite Scrolling in your Laravel application without using any external third party library by using Vuejs. I also implement a simple filter facility to filter posts according to various criteria. Besides Vue js,  I will also use Axios for making HTTP API request and jQuery LoadingOverlay for showing a flexible loading screen. Since we are using jQuery LoadingOverlay we also need to include jQuery in our application. If you don't want to use jQuery then you can use CSS based Loaders.

Let's Start Coding

First thing is to include Vue.js and Axios in our application. There are two methods to include them.

1) Using Laravel Mix

Laravel Mix provides a fluent API for defining Webpack build steps for your Laravel application using several common CSS and JavaScript pre-processors. All you need to do is install Nodejs and NPM in your development system and run

npm install

to install all javascript dependencies in your application. The jQuery, Bootstrap, Vuejs and Axios are already included in this setup.  Now just run 

npm run watch 

to compile your assets. Don't forget to include app.css and app.js files in your Laravel View Layouts.

2) Using CDN

You can also include both Vue.js, Axios and jQuery LoadingOverlay using traditional CDN approach. This method is recommended if you are not going to make Vue Components in Your application and you are just using Vue in one or Two pages for enhancements. In this small demo application, I am also following this approach for simplicity and convenience. Here are the CDN Links for all 3 dependencies.

<script src="https://unpkg.com/[email protected]/dist/vue.js"></script>

<script src="//cdnjs.cloudflare.com/ajax/libs/axios/0.17.1/axios.min.js"></script>

<script src="//cdn.jsdelivr.net/npm/[email protected]/src/loadingoverlay.min.js"></script>

 

Now let's makes our required routes in routes/web.php 

Route::get('demos/infinite-scroll','DemoController@viewInfiniteScroll');
Route::post('demos/infinite-scroll','DemoController@getInfiniteScroll');

 

The first route is for showing the Initial View Page with some data and second route is used for doing the infinite scroll and filters.

Now Let's create our DemoController by running the following command.

php artisan make:controller DemoController

The complete code for DemoController is given below.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\DemoTask;

class DemoController extends Controller
{
    public function viewInfiniteScroll() 
    {    
        $filter = "latest";
    
        if (isset($_GET['filter'])) {
            $filter = $_GET['filter'];
        }

        if ($filter == "latest") {
            $order = "created_at";
            $dir = "DESC";
        } elseif ($filter == "popular") {
            $order = "count";
            $dir = "DESC";
        } elseif ($filter == "oldest"){
            $order = "created_at";
            $dir = "ASC";
        }

        $posts = Post::where('published',1)
                     ->with('tags')
                     ->orderBy($order,$dir)
                     ->limit(5)
                     ->offset(0)
                     ->get();

        return view('demos.infiniteScroll',compact('posts'));
     
    }

    public function getInfiniteScroll(Request $request) 
    {    
        $filter = "latest";
    
        if (isset($_GET['filter'])) {
            $filter = $_GET['filter'];
        }

        if (isset($request->offset)) {
            $offset = $request->offset;
        } else {
            $offset = 0;
        }

        if ($filter == "latest") {
            $order = "created_at";
            $dir = "DESC";
        } elseif ($filter == "popular") {
            $order = "count";
            $dir = "DESC";
        } elseif ($filter == "oldest"){
            $order = "created_at";
            $dir = "ASC";
        }

        $posts = Post::where('published',1)
                     ->with('tags')
                     ->orderBy($order,$dir)
                     ->limit(5)
                     ->offset($offset)
                     ->get();

        $posts = $posts->each(function ($post, $key) {
            $post->body = str_limit($post->body,350); // To reduce json size
        });

        return $posts;
     
    }
}

 

The viewInfiniteScroll method is used for initial loading of the View Page with some data. I am passing filters as URL parameters to maintain the same filter if someone refreshes the page. The default filter is latest and this page will pass 1st 5 posts information to view. 

Note: I am using with method to Eager load Tags information along with Posts. This is done on purpose because we are using Vuejs in our Laravel View and thus Laravel Lazy Loading will not work. So we need to initial load all tags information along with Posts. The Eager loading is also faster than lazy loading so you don't be afraid to use it.

Now the getInfiniteScroll method is called by our Vue during scrolling and Filtering as Axios POST request. This method output 5 Posts each time and the offset value is also increased by 5 each time. Since laravel output collections you don't need to convert posts information into JSON before returning it. This makes development so much easier and Vuejs integration seamless.

Now let's see the Javascript code on the View Page. 

<script type="text/javascript">
  var app = new Vue({
      el: '#app',
      data: {
        posts : {!! $posts !!},
        completed : false,
        progress : false,
        filter : "{{ ( isset($_GET['filter'] )) ? $_GET['filter'] : 'latest'  }}",
      },
      methods: {
        postBody: function(body) {
          var newBody = this.strip_tags(body)
          return newBody.substring(0, 350)+"....";
        },
        strip_tags: function(str, allow) {
          // making sure the allow arg is a string containing only tags in lowercase (<a><b><c>)
          allow = (((allow || "") + "").toLowerCase().match(/<[a-z][a-z0-9]*>/g) || []).join('');

          var tags = /<\/?([a-z][a-z0-9]*)\b[^>]*>/gi;
          var commentsAndPhpTags = /<!--[\s\S]*?-->|<\?(?:php)?[\s\S]*?\?>/gi;
          return str.replace(commentsAndPhpTags, '').replace(tags, function ($0, $1) {
            return allow.indexOf('<' + $1.toLowerCase() + '>') > -1 ? $0 : '';
          });
        },
        getPosts: function(){
          
          if (history.pushState) {
            var newurl = window.location.protocol + "//" + window.location.host + window.location.pathname + '?filter=' + this.filter;
            window.history.pushState({path:newurl},'',newurl);
          }

          $.LoadingOverlay("show");
          this.posts = "";
          self = this;
          
          axios.post(newurl)
               .then(function (response) {
                  $.LoadingOverlay("hide"); 
                  self.posts = response.data;
                  self.completed = false;
                })
               .catch(function (error) {
                    console.log(error);
                });

        },
        infiniteScroll: function(){
          var newurl = window.location.protocol + "//" + window.location.host + window.location.pathname + '?filter=' + this.filter ;

          self = this;

          if (!this.completed &&  !this.progress) {
            this.progress = true
            axios.post(newurl,{
                  offset: self.posts.length ,
                 })
                 .then( function(response) {
                    if (response.data.length) {
                      self.posts = self.posts.concat(response.data);
                      self.progress = false;  
                    } else {
                      self.progress = false;  
                      self.completed = true;
                    }
                  })
                 .catch(function (error) {
                    console.log(error);
                  });;
          }

        },
      },
      mounted:function(){
        if (history.pushState) {
            var newurl = window.location.protocol + "//" + window.location.host + window.location.pathname + '?filter=' + this.filter ;
            window.history.pushState({path:newurl},'',newurl);   
        }

        window.addEventListener('scroll', this.infiniteScroll);
     },
  });
</script>

 

As you can see that here we are initializing Vue js and passing the parameter to class as an object. Here we will pass our Post collection to Vuejs data as follows,

posts : {!! $posts !!},

 

The getPosts function is called when a user selects any filters and infiniteScroll function is automatically called by the scroll event which we mounted at the time of initialization of the Vue Instance. The infiniteScroll function call an axios POST request to the getInfiniteScroll method in our DemoController and we get the next 5 sets of Posts collection. Since Axios is promise based we will get the successful response to the then method and we just concat the new response to old Post data which is a collection of Javascript Object. The next server call only happened after the previous one is successfully completed by using process flag. Thus every time posts are appended in the correct order and we don't slow down the system by frequent Ajax calls. 

The window.history.pushState is used to append the new URL to the browser without refreshing it so that even when the user manually refreshes the browser we will get same filter orders.

Now you can loops the Posts and tags inside a Post in HTML part of view page as below

<div v-for="post in posts">
    <h2>@{{ post.title }}</h2>
    
    <div v-for="tag in post.tags">
        @{{ tag.name }}
    </div>
</div>

Very simple right. We can override the default laravel blade templates by using @ symbol at front. 

Now let's see the complete View page codes which I used to build this demo. Remember you will see some fancy class names but don't worry about it as CSS and HTML parts changes according to your requirements. I just used Material design lite and Bootstrap 3 in creating ShareurCodes.

@extends('main')

@section('title','Infinite Scroll with Filters in Laravel Using Vuejs - Demo')

@section('stylesheets')

<style type="text/css">
  [v-cloak] {
    display: none;
  }
</style>
    
@stop

@section('content')
  <div id="app">
     <h3 class="text-center title-color">Infinite Scroll with Filters in Laravel Using Vuejs - Demo</h3>

    <div class="row">
      <div class="col-md-10 col-lg-offset-1">
        <div class="mdl-grid mdl-cell mdl-cell--12-col  mdl-shadow--4dp" v-cloak>
          <div class="col-xs-6 col-sm-8 col-md-9">
            <h4 class="text-capitalize">@{{ filter }} Posts</h4>
          </div>
          <div class="col-xs-6 col-sm-4 col-md-3 " >
            <form class="form-inline" style="padding-top: 20px">
              <div class="form-group">
                <label class="collft control-label">Filter By:</label>
                <select class="form-control" v-model="filter" id="choose" v-on:change="getPosts()">
                  <option value="latest" >Latest Posts</option>
                  <option value="popular" >Most Popular</option>
                  <option value="oldest" >Oldest Posts</option>
                </select>
              </div> 
            </form>
          </div>
          <div class="clearfix"></div>
        </div>  
      </div>
    </div>
    <div class="row">
      <div class="col-md-10 col-md-offset-1">

        <div class="mdl-grid mdl-cell mdl-cell--12-col  mdl-shadow--4dp" v-cloak v-for="post in posts">
          <div class="post">
            <a target="_blank" :href="'/blog/'+post.slug" class="nounderline">
              <h2 class="post-title">@{{ post.title }}</h2>
            </a>
            <h5 class="post-date">Published: @{{ post.created_at }}</h5> 
            <p class="text-justify" v-html="postBody(post.body)"></p>
            <a target="_blank" :href="'/blog/'+post.slug" class="demo-nav__button" title="Read More"><b>Read More</b>
              <button class="mdl-button mdl-js-button mdl-js-ripple-effect mdl-button--icon" data-upgraded=",MaterialButton,MaterialRipple">
              <i class="material-icons" role="presentation">arrow_forward</i>
              <span class="mdl-button__ripple-container"><span class="mdl-ripple is-animating" style="width: 92.5097px; height: 92.5097px; transform: translate(-50%, -50%) translate(7px, 11px);"></span></span></button>
            </a>
          </div>

          <div class="tags blog-space">
                <h5 >
                   <a v-for="(tag,index) in post.tags" v-if="index < 4" :href=" '/blog/tags/'+tag.id" class="nounderline" style="margin-right: 10px;" target="_blank">
                      <span class="label label-default m-r-10"> @{{ tag.name }} </span>
                   </a>
                </h5>
          </div>

        </div>
        
        <div class="text-center" v-cloak v-if="!completed">
          <img v-if="progress" src="{{ url('images/ajax-loader.gif') }}">
        </div>
        <div class="text-center" v-cloak v-if="completed">
          <h5>No More Posts Found!</h5>
        </div>

      </div>
    </div>
    <hr>
    <h5>For the complete tutorial of how to make this demo app visit the following <a href="https://shareurcodes.com/blog/create-infinite-scroll-with-filters-in-laravel-using-vuejs">Link</a>.</h5>
  </div>     
@stop

@section('scripts')

<script src="https://unpkg.com/[email protected]/dist/vue.js"></script>

<script src="//cdnjs.cloudflare.com/ajax/libs/axios/0.17.1/axios.min.js"></script>

<script src="//cdn.jsdelivr.net/npm/[email protected]/src/loadingoverlay.min.js"></script>

<script type="text/javascript">
  var app = new Vue({
      el: '#app',
      data: {
        posts : {!! $posts !!},
        completed : false,
        progress : false,
        filter : "{{ ( isset($_GET['filter'] )) ? $_GET['filter'] : 'latest'  }}",
      },
      methods: {
        postBody: function(body) {
          var newBody = this.strip_tags(body)
          return newBody.substring(0, 350)+"....";
        },
        strip_tags: function(str, allow) {
          // making sure the allow arg is a string containing only tags in lowercase (<a><b><c>)
          allow = (((allow || "") + "").toLowerCase().match(/<[a-z][a-z0-9]*>/g) || []).join('');

          var tags = /<\/?([a-z][a-z0-9]*)\b[^>]*>/gi;
          var commentsAndPhpTags = /<!--[\s\S]*?-->|<\?(?:php)?[\s\S]*?\?>/gi;
          return str.replace(commentsAndPhpTags, '').replace(tags, function ($0, $1) {
            return allow.indexOf('<' + $1.toLowerCase() + '>') > -1 ? $0 : '';
          });
        },
        getPosts: function(){
          
          if (history.pushState) {
            var newurl = window.location.protocol + "//" + window.location.host + window.location.pathname + '?filter=' + this.filter;
            window.history.pushState({path:newurl},'',newurl);
          }

          $.LoadingOverlay("show");
          this.posts = "";
          self = this;
          
          axios.post(newurl)
               .then(function (response) {
                  $.LoadingOverlay("hide"); 
                  self.posts = response.data;
                  self.completed = false;
                })
               .catch(function (error) {
                    console.log(error);
                });

        },
        infiniteScroll: function(){  
          var newurl = window.location.protocol + "//" + window.location.host + window.location.pathname + '?filter=' + this.filter ;

          self = this;

          if (!this.completed &&  !this.progress) {
            this.progress = true
            axios.post(newurl,{
                  offset: self.posts.length ,
                 })
                 .then( function(response) {
                    if (response.data.length) {
                      self.posts = self.posts.concat(response.data);
                      self.progress = false;  
                    } else {
                      self.progress = false;  
                      self.completed = true;
                    }
                  })
                 .catch(function (error) {
                    console.log(error);
                  });;
          }

        },
      },
      mounted:function(){
        if (history.pushState) {
            var newurl = window.location.protocol + "//" + window.location.host + window.location.pathname + '?filter=' + this.filter ;
            window.history.pushState({path:newurl},'',newurl);   
        }
        window.addEventListener('scroll', this.infiniteScroll);
      },
  });
</script>

@stop

 

I used v-cloak directives to hide the uncompiled Vue templates from showing in HTML at time of loading. This directive will remain on the element until the associated Vue instance finishes compilation. Combined with CSS rules such as [v-cloak] { display: none }, this directive can be used to hide un-compiled moustache bindings until the Vue instance is ready. 

The output image of above program is given below.

Infinite Scroll with Filters in Laravel Using Vuejs - Demo ShareurCodes.com

 

The Demo

You can demo the above application by visiting following link.

https://shareurcodes.com/demos/infinite-scroll

If anybody has any suggestions or doubts or need any help comment below and I try will respond to every one of you as early as possible.


Web development
30th Mar 2018 12:26:35 PM
PHP Laravel Javascript Vue.js
14658

ShareurCodes

ShareurCodes is a code sharing site for programmers to learn, share their knowledge with younger generation.