The first time I needed to paginate data in a Rails site, I went straight for the de-facto standard, which, since Rails 2.0, has undoubtedly been will_paginate. However, it didn’t take me long to discover it couldn’t do all that I wanted it to.
Most importantly, I wanted to be able to redirect from a resource member action (the update action, say) back to the index action, with the page set so that the edited resource would be part of the paged list. I couldn’t see a way to do that with will_paginate. I found the will_paginate helper a bit messy – ever heard of block helpers? And finally, I wanted my pages to be objects, not just numbers. This would let me load them in controllers and pass them to named routes and have them just work. Will_paginate didn’t seem to fit the bill.
Now don’t get me wrong; will_paginate must be pretty great – it’s the third most watched repo on GitHub as I write this. But choice is always good, and to me, will_paginate seems a bit bloated and ill-fitting to the way I like to structure my code.
So, naturally, I rolled my own pagination solution. I’ve finally packaged it up and released it as a new ActiveRecord pagination gem, PagedScopes. It’s everything I need in Rails pagination and nothing I don’t. It’s also lightweight and pretty solid. Check it out!
The bullet-point summary of the PagedScopes gem goes something like this:
- Pages are instances of a class which belongs to the collection it’s paginating;
- Pages can be found by number or by contained object;
- Each page has its own paged collection, which is a scope on the underlying collection; and
- Flexible, Digg-style pagination links are achieved using a block helper.
Let’s take a look at how pagination works with PagedScopes. Consider a collection of articles obtained using a published
named scope.
@articles = Article.published => [#<Article id: 1, title: "Article #1">, ..., #<Article id: 5, title: "Article #5">] @articles.count => 5
The PagedScopes gem adds a per_page
attribute directly to named_scope
collections (and to association collections, too). This value determines how many objects each page contains, and needs to be set before we can paginate the collection:
@articles.per_page = 2 => 2
Paginating this collection will now give us three pages.
How do we access these pages? By calling pages
, the other main method added to ActiveRecord
collections. It returns an enumerated class, the instances of which represent the pages of the collection. We can interact with the pages class in some familiar ways:
@articles.pages => #<Class:0x24ea99c> @articles.pages.count => 3 @articles.pages.first => #<Page, for: Article, number: 1> @articles.pages.find(1) => #<Page, for: Article, number: 1> @articles.pages.last => #<Page, for: Article, number: 3> @articles.pages.find(4) => # PagedScopes::PageNotFound: couldn't find page number 4 @articles.pages.all => [#<Page, for: Article, number: 1>, #<Page, for: Article, number: 2>, #<Page, for: Article, number: 3>] @articles.first.to_param => "1"
Looks just like any other model – each page is its own self-contained object, as it should be. We can access the collection objects in the page using the same name as the underlying model. In our example, our collection contains Article
instances, so the articles in the page are accessed using an articles
method:
@articles.pages.first.articles => [#<Article id: 1, title: "Article #1">, #<Article id: 2, title: "Article #2">] @articles.pages.last.articles => [#<Article id: 5, title: "Article #5">] @articles.pages.map(&:articles).map(&:size) => [2, 2, 1] @articles.pages.map { |page| page.articles.map(&:title) } => [["Article #1", "Article #2"], ["Article #3", "Article #4"], ["Article #5"]]
So far, so good. Bu what, exactly, is return by the articles
method? Let’s see:
@articles.pages.first.articles.class => ActiveRecord::NamedScope::Scope @articles.pages.first.articles.send(:scope, :find) => {:conditions=>"published_at IS NOT NULL", :offset=>0, :limit=>2} @articles.pages.last.articles.send(:scope, :find) => {:conditions=>"published_at IS NOT NULL", :offset=>4, :limit=>2} @articles.send(:scope, :find) => {:conditions=>"published_at IS NOT NULL"}
Yep, it’s just a scope on the parent collection, with :limit
and :offset
added according to the page number. This is kinda important. It means that the objects in the paged collection will not load from the database until they are referenced. We can pass around page objects in view helpers and named routes and so on, without worrying about inadvertently loading the paged data.
One particularly nice feature of the library is that we can find a page by identifying an object the page contains.
article = Article.find(3) => #<Article id: 3, title: "Article #3"> @articles.pages.find_by_article(article) => #<Page, for: Article, number: 2> article = articles.find(8) => #<Article id: 8, title: "Article #8"> @articles.pages.find_by_article(article) => nil @articles.pages.find_by_article!(article) => # PagedScopes::PageNotFound: #<Article id: 8, title: "Article #8"> not found in scope
This is really handy if you want to redirect from a resource member action to the paged of the index containing the edited object. (More on this later.)
This is implemented using the code I described in my previous post. As a result you get a couple of freebies on your ActiveRecord objects:
article = Article.scoped(:order => "title ASC").find(3) => #<Article id: 3, title: "Article #3"> article.next => #<Article id: 4, title: "Article #4"> article = Article.scoped(:order => "title DESC").find(3) => #<Article id: 3, title: "Article #3"> article.next => #<Article id: 2, title: "Article #2"> article.previous => #<Article id: 4, title: "Article #4">
In other words, you can find the next
and previous
objects for any object in a collection. This provides an easy way to link to neighbouring objects (e.g. older and newer posts in a blog).
It’s important to store the paged scope or association collection in a variable, rather than refer to it directly. In other words:
# Do this: @articles = @user.articles.published # or whatever => [#<Article ...>, ..., #<Article ...>] @articles.per_page = 5 => 5 @articles.per_page => 5 # Don't do this: @user.articles.published.per_page = 5 => 5 @user.articles.published.per_page => nil
This is because paged scopes and association collections return new instances each time they’re called. You need to hang onto them to set the per_page
and then get the pages.
The most common way to represent a paginated collection in an URL is to tack on the page number as a query paramater: http://www.example.com/articles?page=3
, for example.
I’m not a fan of this approach at all. For starters, it’s a bit ugly. More importantly, it won’t work with standard Rails page caching, which ignores query parameters.
I prefer to think of pagination as just another scoping of the collection. Just as we have paths like /users/9/articles
, I prefer a paged collection to have paths like /pages/2/articles
(or /users/9/pages/2/articles
, for that matter).
To this end, the Paged Scopes gem adds a :paged
option to the Rails resources
mapper. We’ll use this option to define the routes for our articles:
ActionController::Routing::Routes.draw do |map| map.resources :articles, :paged => true end
Checking our routes using rake routes
:
articles GET /articles(.:format) {:controller=>"articles", :action=>"index"} POST /articles(.:format) {:controller=>"articles", :action=>"create"} new_article GET /articles/new(.:format) {:controller=>"articles", :action=>"new"} edit_article GET /articles/:id/edit(.:format) {:controller=>"articles", :action=>"edit"} article GET /articles/:id(.:format) {:controller=>"articles", :action=>"show"} PUT /articles/:id(.:format) {:controller=>"articles", :action=>"update"} DELETE /articles/:id(.:format) {:controller=>"articles", :action=>"destroy"} page_articles GET /pages/:page_id/articles(.:format) {:controller=>"articles", :action=>"index"}
Just your standard set of resource routes, with one extra – the paged articles index route, last in the list. Specifying the :paged
option in the mapping yields this extra route for use in our index actions. (Everything else remains the same.)
Want a bit more flexibility? We can pass :as
or :name
options to the paged option if needed:
map.resources :articles, :paged => { :as => :pagina } map.resources :users, :paged => { :name => :group }
Which would produce these routes:
page_articles GET /pagina/:page_id/articles(.:format) {:controller=>"articles", :action=>"index"} group_users GET /groups/:group_id/users(.:format) {:controller=>"users", :action=>"index"}
(This is likely only to be useful in rare situations. One example would be paginating more than one collection in a single view.)
A more complex example:
map.resources :articles, :collection => { :published => :get }, :paged => true
Which would produce these paged routes:
published_page_articles GET /pages/:page_id/articles/published(.:format) {:controller=>"articles", :action=>"published"} page_articles GET /pages/:page_id/articles(.:format) {:controller=>"articles", :action=>"index"}
By default paged routes are generated for the :index
action and any :collection
actions that specify the :get
method. To override the default behaviour, simply be explicit about which paged routes are required:
map.resources :articles, :collection => { :published => :get }, :paged => { :published => true }
Which would produce only this paged route:
published_page_articles GET /pages/:page_id/articles/published(.:format) {:controller=>"articles", :action=>"published"}
Another complex example:
map.resources :articles, :collection => { :published => :get }, :paged => { :index => true, :published => { :as => :pagina } }
Which would produce these paged routes:
published_page_articles GET /pagina/:page_id/articles/published(.:format) {:controller=>"articles", :action=>"published"} page_articles GET /pages/:page_id/articles(.:format) {:controller=>"articles", :action=>"index"}
In fact, the :as
and :name
options specified in the root of the :paged
will be used for all paged routes where an alternative is not specified.
map.resources :users, :collection => { :active => :get, :blocked => :get, :online => :get }, :paged => { :as => "pagina", :index => true, :active => { :name => "group" }, :blocked => { :as => "blacklist" } }
Which would produce these three paged routes:
blocked_page_users GET /blacklist/:page_id/users/blocked(.:format) {:controller=>"users", :action=>"blocked"} active_group_users GET /pagina/:group_id/users/active(.:format) {:controller=>"users", :action=>"active"} page_users GET /pagina/:page_id/users(.:format) {:controller=>"users", :action=>"index"}
Notice that there is no paged route for the :online
collection action.
OK, so we have our pages represented in our article index route. Let’s turn to the articles controller next.
I believe there is diverging practice on this, but in controllers I always prefer to load the collection and object in before filters, typically along the lines of:
class ArticlesController < ApplicationController before_filter :get_articles before_filter :get_article, :only => [ :show, :edit, :update, :destroy ] before_filter :new_article, :only => [ :new, :create ] # actions here ... protected def get_articles @articles = @user.articles.scoped(:order => "created_at DESC") # or whatever end def get_article @article = @articles.find_from_param(params[:id]) end def new_article @article = @articles.new(params[:article]) end end
It’s a very consistent way to write RESTful controllers. The @articles
collection is always created, which is OK, since it’s just a scope or an association and no records are actually loaded. For the member actions, the collection instance is either loaded from the collection or built from it, depending on whether the action is creating a new record (new, create) or modifying an existing once (show, edit, update, destroy).
Using this pattern, paginating the collection fits naturally as another before filter once the collection is set. To this end, Paged Scopes provides a tailored paginate
class method to do just that:
class ArticlesController < ApplicationController before_filter :get_articles before_filter :get_article, :only => [ :show, :edit, :update, :destroy ] before_filter :new_article, :only => [ :new, :create ] paginate :articles, :per_page => 3, :path => :page_articles_path ...
This paginate
method basically adds another before_filter
which loads the current page from the collection. As arguments, it takes an optional collection name and an options hash. If omitted, the collection name is inferred from the controller name. (Hence, in the above example, we could have omitted the :artices
arguments and @articles
would then be inferred from the ArticlesController
name. Hurrah for naming conventions!)
You can pass a few options to the paginate
method:
- A
:per_page
option sets the page size on the collection if you specify it. (This option can be omitted ifper_page
has already been set on the collection.) - A
:path
option will set the path proc for the paginator to be the controller method you specify. In the above example we’ve set it to a named route (page_articles_path
), but it could equally well be a method you’ve defined later in the controller. (This could be useful if you want to use a polymorphic path, for example.) - a
:name
option is available if you want to refer to your pages by a different class name (unlikely).
Any other options will be passed through to the filter definition. So you can use filter options, such as :if
, :only
and :except
, just as you would for any other filter.
Aside from setting the options you specify, the main job of the paginate
filter is to set the page as an instance variable. Controller actions will then have a @page
variable available to be used for pagination. The page number is determined from three locations in order of priority.
- If an object of the collection is present (an
@article
, in our example), the page containing that object is loaded (unless the object is a new record). - Failing that, the request params are examine for a
:page_id
. If present, that page number is loaded. (This fits with the paged resource routes described earlier.) - Failing that, the first page is loaded by default.
Loading the page for a member action (show, edit, update) might not seem useful at first. Its utility becomes apparent when we’re redirecting though:
def update if @article.save flash[:notice] = "Success!" redirect_to page_articles_path(@page) else ... end end
The page is used to redirect to the index at the page containing the edited object. Very polite to users! (Views can also link back to the paged index in a similar manner.)
The basic idea is to render a row of numbered links for a few pages either side of the one being viewed. This is referred to as the inner window. An outer window is often also included – this shows links for the first and last few pages at the start and end of the list. Usually, next page and previous page links are also sandwiched around the numbered links.
The will_paginate rdoc has some good links to articles on pagination UI design:
- a Yahoo Design Pattern Library article describing two styles of pagination;
- a Smashing Magazine article with good practices and examples; and
- another article with heaps of examples, both good and bad.
In the will_paginate gem, the eponymous will_paginate
view helper is provided to render these links in your view. It seems to work well, but one look at the method’s options gives you an idea what you’ll be up for if you want to customize the HTML structure of your pagination links. Want to render your pagination links as a list? You’ll have to write your own LinkRenderer
subclass. (Have fun with that.)
There has to be a better way. There is of course, and it comes from a less-is-more approach.
With the PagedScopes gem, each page has an associated paginator
which provides some simple methods for generating page links. First, we need to call set_path
to tell the paginator how to generate links for a pages:
@page.paginator.set_path { |page| page_articles_path(page) }
The block we supply will be used by the paginator to generate a paged URL whenever one is needed.
(Note that the controller paginate
method I presented in the last article can also be used to set the path proc by using the :path
option.)
Next, we use the window
method to render the page links. We supply a block which the paginator will call for each page in the window, allowing us to render the link exactly as we want tp. Let’s render that list we were talking about:
<ul> <% @page.paginator.window(:inner => 2, :outer => 1) do |page, path, classes| %> <% content_tag_for :li, page, :class => classes.join(" ") do %> <%= link_to_if path, page.number, path %> <% end %> <% end %> </ul>
Here we’ve specified an inner window of size 2 (meaning we want links for two pages either side of the current page) and an outer window of size 1 (meaning we want links for just the first and last pages).
The window
helper passes a succession of pages to our block for us to render. The block arguments are:
- The page itself, from which we can get the page number.
- The path for the page, produced using the
set_path
proc we’ve already specified. If the page is the current page, then nil is passed as the path – this is because we shouldn’t render a link for the current page. (Hence our use oflink_to_if
.) - An optional array of classes describing the link. Possible values for the classes are
:selected
if the page is the current page,:gap_before
if there’s a gap in the numbering before the page, and:gap_after
if there’s a gap after. You can use these as you see fit, but they’re intended to be passed through to your link container as classes for styling. (We’ve done this above with the:class => classes.join(" ")
option.)
Within the block, the page link can be rendered as we please. In our example we’re putting it inside an <li>
element. For page 7, the window
function would produce the following markup:
<ul> <li class="page gap_after" id="page_1"> <span><a href="/pages/1/articles">1</a></span> </li> <li class="page gap_before" id="page_5"> <span><a href="/pages/5/articles">5</a></span> </li> <li class="page" id="page_6"> <span><a href="/pages/6/articles">6</a></span> </li> <li class="page selected" id="page_7"> <span>7</span> </li> <li class="page" id="page_8"> <span><a href="/pages/8/articles">8</a></span> </li> <li class="page gap_after" id="page_9"> <span><a href="/pages/9/articles">9</a></span> </li> <li class="page gap_before" id="page_12"> <span><a href="/pages/12/articles">12</a></span> </li> </ul>
Add some styling, using our classes to distinguish the currently selected pages and to add a separator where there are numbering gaps:
li.page { display: inline } li.page a { text-decoration: none } li.page span { border: 1px solid gray; padding: 0.2em 0.5em } li.page.selected span, li.page span:hover { background: gray; color: white } li.page.gap_before:before { content: "..." }
The result: a nice-looking set of page links.
[Refer to the original article at code.matthewhollingworth.net for correctly rendered examples!]
Too easy!
How do we get add previous and next links? This is pretty easy, too – just specify the :extras
we want as an option. (Choose from :first
, :previous
, :next
and :last
.) Those symbols will be passed to our block as the page when they need to be rendered.
We’ll move our pagination links to a helper for clarity:
module ArticlesHelper MARKER = { :previous => "< newer", :next => "older >" } def article_page_links @page.paginator.window(:inner => 2, :outer => 1, :extras => [ :previous, :next ]) do |page, path, classes| content_tag :li, :class => (classes << :page).join(" ") do content_tag :li, link_to_if(path, MARKER[page] || page.number, path) end end end end
Which renders as follows (for page 4 this time):
[Refer to the original article at code.matthewhollingworth.net for correctly rendered examples!]
Just what we want!
Links for the :first
and :last
pages can also be specified as extras; these will appear outside the previous and next links. (If you use these extras, you’ll want to omit the :outer
window option.)
You can install the PagedScopes gem as follows:
gem sources -a http://gemcutter.org # just once sudo gem install paged_scopes
And in your config/environment.rb
, if you’re on Rails:
config.gem "paged_scopes", :source => "http://gemcutter.org"
Peruse the code at GitHub.
Copyright © 2009 Matthew Hollingworth. See LICENSE for details.