Paginated Collections with Ember.js + Solr + Rails

Reading Time: 6 minutes

This time, I would like to show you how to add a simple pagination helper to your Ember.js application.

For this example, I will be using Rails + Solr for the backend and Ember.js as my frontend framework.

I am doing this with Rails and Solr, but you can do it using other backend frameworks, as long as the JSON's response resembles what we have here:

{
  "resource_collection":
  [
    {
      "id": 1,
      "page": 1,
      "per_page": 5,
      "total_entries": 15,
      "total_pages": 3,
      "resource_ids": [1,2,3,4,5]
    }
  ],
  "resources":
  [
    {
      "id": 1,
      "property": "value,
      "property_2": "other value"
    },
    {
      // each collection's element ...
    }
  ]
}

Don't worry if this JSON looks weird to you, its structure will be explained in a while.

I am considering that you already have a Rails + Ember.js web application. So I would like to start showing you my Solr setup.

Solr setup

image alt

First things first, if you don't have Solr in your Rails app, you must add the following gems to the Gemfile:

gem 'sunspot_rails'
gem 'sunspot_solr'

Then run the following commands:

bundle install
bundle exec rails generate sunspot_rails:install
bundle exec rake sunspot:solr:start

The first command installs the gems. The second one sets up Solr in your Rails app. Finally, the third one starts the Solr service.

If this is your first contact with Solr, you can read more about it here

Adding the searchable block to the Model to paginate

In my case, I wanted to paginate my Post resource (model). So I added this code to my app/models/post.rb:

searchable do
  text :title, :content, :status

  integer :id
  integer :author_id
  time    :published_on
end

Creating the Solr search model

The next thing I did, was to create a Solr search model, this class is intended to return a paginated collection of Posts. I called the file as app/models/post_search.rb and here is the code:

class PostSearch
  attr_accessor :page, :per_page, :filters

  def initialize(params)
    @page = params[:page] || 1
    @per_page = params[:per_page] || 5
    @filters = params[:filters] || {}
  end

  def search
    Post.search do
      paginate page: page, per_page: per_page
      # Other search parameters... Irrelevant to this example
    end
  end
end

If you are interested in adding more customization to your Solr Search model or knowing more about the Sunspot gem, you can read this great blog post.

Adding the special Serializer

This is a very important class, it will help us send the correct JSON to Ember.js, I put the following code inside of app/serializers/post_search_serialiazer.rb:

class PostSearchSerializer
  attr_accessor :search
  delegate :current_page, :per_page, :total_entries, :total_pages, to: :pagination_info

  def initialize(params)
    @search = PostSearch.new(params).search
  end

  def serialize
    {
      post_search:
      [
        {
          id: current_page,
          page: current_page,
          per_page: per_page,
          total_entries: total_entries,
          total_pages: total_pages,
          post_ids: post_ids
        }
      ],
      posts: search_results
    }
  end

  private

  def post_ids
    search_results.map(&:id)
  end

  def search_results
    search.results
  end

  def pagination_info
    search.hits
  end
end

If you remember the first JSON example, the resource_collection key makes reference to my post_search key from the example above. Also, the resources key is the same than my posts key. You will need to change this according to your model 😉

Customizing the Route

This is easy, just add the following code to your config/routes.rb:

resources :post_search, defaults: {format: :json}

Creating the Controller

As you can imagine, it's necessary to create a Rails controller in order to provide an API to our Ember.js application.

I have just created the following file app/controllers/post_search_controller.rb:

class PostSearchController < ApplicationController
  def index
    @post_search = PostSearchSerializer.new(params).serialize

    render json: @post_search
  end
end

As you can observe, I just used the special serializer to return the paginated collection, also known as the Solr search results.

And that's it! We are ready to start working with Ember.js and the pagination helper.

The Frontend

image alt

Before we start, there's something I want to say, just in case you are using the ember-rails gem. Please make sure that you are requiring the helpers before the models, in your app/assets/javascripts/your_app.js.coffee file. For instance, I changed it to this:

#= require ./store
#= require_tree ./helpers
#= require_tree ./models
#= require_tree ./controllers
#= require_tree ./views
#= require_tree ./helpers
#= require_tree ./templates
#= require_tree ./routes
#= require_tree ./initializers

Paginated Collection Helper

I have created a new Model, Serializer and Controller on Rails. So, all this means that I'm going to need a new Ember.js model, in order to get it working. Remember, the store is in charge of finding the models through the adapter. It will communicate with the new Rails controller to serialize the new model.

From my point of view, we can have many paginated collections inside an application. So, one way to avoid code duplication is the use of Ember.js Mixins. The following mixin app/assets/javascripts/helpers/paginated_collection_helper.js.coffee has the responsibility of defining the pagination's attributes:

App.PaginatedCollection = Ember.Mixin.create
  perPage: DS.attr('number')
  totalPages: DS.attr('number')
  totalEntries: DS.attr('number')
  page: DS.attr('number')

Then, I am ready to include it in my new Ember.js model. This model is called PostSearch and it's located at app/assets/javascripts/models/post_search.js.coffee, please check it out:

App.PostSearch = DS.Model.extend(App.PaginatedCollection,
  posts: DS.hasMany('post')
)

This way, if your application needs more paginated collections, you just will need to create the corresponding models and include the PaginatedCollection mixin in the recently created models.

Paginate It helper

This is where the magic happens! This mixin holds all the logic for the pagination. It handles pages setup and page changes. The file is called app/assets/javascripts/helpers/paginate_it_helper.js.coffee and here is the code:

App.PaginateIt = Ember.Mixin.create
  maxPerPage: 20,

  setup: (resource) ->
    @set('currentPage', @get('content.content.firstObject'))
    @set(resource, @get("currentPage.#{resource}"))

  perPage: (->
    @get('pagination.perPage')
  ).property('pagination')

  hasPrevious: (->
    @get('pageNumber') > 1
  ).property('pageNumber')

  hasNext: (->
    @get('pageNumber') < @get('pagination.totalPages')
  ).property('pageNumber')

  pagination: (->
    @setup(@get('resource'))
    @get('content.content.firstObject')
  ).property('content')

  pageNumber: (->
    parseInt(@get('pagination.page'))
  ).property('pagination')

  pages: (->
    self = @
    pages = Em.A()
    [@get('firstPage')..@get('lastPage')].forEach (page) ->
      pages.pushObject {
        pageNumber: page,
        active: page is self.get('pageNumber')
      }

    pages
  ).property('@each')

  firstPage: (->
    firstPage = 1
    firstPage = @get('pageNumber') - 5 if @get('pageNumber') - 5 > 0
    firstPage
  ).property('pageNumber')

  lastPage: (->
    lastPage = @get('pagination.totalPages')
    lastPage = @get('firstPage') + 5 if @get('firstPage') + 5 < @get('pagination.totalPages')
    lastPage
  ).property('pagination.totalPages')

  changePage: (pageNumber) ->
    params = {
      page: pageNumber,
      per_page: @get('perPage')
    }

    self = @

    @store.find('postSearch', params).then (postSearch) ->
      self.set('content', postSearch)

  switchPerPage: (perPage) ->
    @set('maxPerPage', @get('perPage'))
    @set('perPage', perPage)

  actions:
    goPage: (page) ->
      @changePage(page)

    goNext: ->
      @changePage(@get('pageNumber') + 1) if @get('hasNext')

    goPrev: ->
      @changePage(@get('pageNumber') - 1) if @get('hasPrevious')

    goFirst: ->
      @changePage(1) if @get('hasPrevious')

    goLast: ->
      @changePage(@get('pagination.totalPages')) if @get('hasNext')

    changePerPage: (newPerPage) ->
      @switchPerPage(newPerPage)
      @changePage(1)

The partial

I have almost all the necessary code to get the pagination working. Let me show you the template for this pagination helper. The file is located at app/assets/javascripts/templates/helpers/_pagination.handlebars:

<div class="pagination-container">
  <ul class="pagination">
    <li {{bindAttr class="hasPrevious::disabled"}}>
      <span {{action "goFirst"}}>&laquo;</span>
    </li>
    <li {{bindAttr class="hasPrevious::disabled"}}>
      <span {{action "goPrev"}}>&larr;</span>
    </li>
    {{#each page in pages}}
      {{#with page}}
        <li {{bindAttr class="active"}}>
          <span {{action "goPage" pageNumber}}>{{pageNumber}}</span>
        </li>
      {{/with}}
    {{/each}}
    <li {{bindAttr class="hasNext::disabled"}}>
      <span {{action "goNext"}}>&rarr;</span>
    </li>
    <li {{bindAttr class="hasNext::disabled"}}>
      <span {{action "goLast"}}>&raquo;</span>
    </li>
  </ul>

  <button class='btn btn-success btn-xs change-per-page' {{action changePerPage maxPerPage}}>Show {{maxPerPage}}</button>
</div>

I'm making use of Bootstrap's CSS classes for pagination.

Preparing the Route

One last necessary step before including the PaginateIt mixin in our controllers, is to change the Route to use our custom PaginatedCollection model. For instance, I changed my PostsRoute from this:

App.PostsRoute = Ember.Route.extend
  model: ->
    @store.find('post')

To this:

App.PostsRoute = Ember.Route.extend
  model: ->
    @store.find('postSearch')

By doing this we are switching to the model that brings the Paginated Collection.

You will also need to append this line to the app/assets/javascripts/store.js.coffee file:

Ember.Inflector.inflector.uncountable('post_search')

That's to prevent the Assertion failed: Error while loading route... error. This is caused by the conventions that Ember.js has defined. The particular convention is to make an HTTP request to the plural form of the model name, which in this case is post_searches but in our server I haven't declared such resource.

In a nutshell, the previous line is setting the post_search word as uncountable, which means that it does not have a plural form, it remains unchanged.

How to use it?

It is not as hard as it seems. You just need to attach the PaginateIt mixin to the corresponding controller, add a property and that's it! You got it working like a charm. I want to show you an example. This was my PostsController without pagination:

App.PostsController = Ember.ArrayController.extend(
  needs: ['currentUser']
  currentUser: Ember.computed.alias('controllers.currentUser')
)

This is my PostsController with pagination:

App.PostsController = Ember.ArrayController.extend(App.PaginateIt,
  needs: ['currentUser']
  currentUser: Ember.computed.alias('controllers.currentUser')
  resource: 'posts'
)

The resource property specifies the name of the property which I will use on my templates.

So, instead of my old Posts index template:

{{#each post in content}}
  <h4>{{#linkTo 'post' post}}{{post.title}}{{/linkTo}}</h4>
  <p class='text-right'>
    <small>
      Published: {{friendlyDate post.publishedOn}}
      <em>by</em>
      <strong>{{fullName post.author}}</strong>
    </small>
  </p>
  <p>{{post.content}}</p>
{{/each}}

Now I have this:

{{#each post in posts}}
  <h4>{{#linkTo 'post' post}}{{post.title}}{{/linkTo}}</h4>
  <p class='text-right'>
    <small>
      Published: {{friendlyDate post.publishedOn}}
      <em>by</em>
      <strong>{{fullName post.author}}</strong>
    </small>
  </p>
  <p>{{post.content}}</p>
{{/each}}

<br>

{{ partial "helpers/pagination" }}

As mentioned previously, I won't use {{#each post in content}} anymore. Instead, I will be using {{#each post in posts}} now that I have included the PaginateIt mixin.

We are done!

A working example?

Yes, of course! You can take a look at this repo and follow the Readme's instructions.

  • Pro-tip: if you are applying this tutorial on your own application, please remember to run: bundle exec rake sunspot:reindex before sending hate mail my way because the search method on YourSearch model does not return anything.

Please feel free to comment!

What topics would you like me to cover in the next blogpost? I will be back soon with an article covering your suggestions.

Notes

This example is running under the following setup.

  • Ember : 1.4.0-beta.1+canary.f3a696df
  • Ember Data : 1.0.0-beta.4+canary.7af6fcb0
  • Handlebars : 1.0.0
  • jQuery : 1.10.2

Thanks for reading!

0 Shares:
You May Also Like