Rails Tricks, part 2: Mislav's Will_Paginate

09 Oct 2009 – Warsaw

The industry standard

If you ever had a chance to meet Mislav Marohnic (or see him on a video), for the first few minutes you were probably thinking how this crazy and hyperactive guy could have written one of the most popular Rails library. But after some time with Mislav it becomes obvious that he’s an insanely smart guy able to create some of the best ruby code I’ve ever seen.

All the pages, linked everywhere

The Problem (AKA “design”)

In one project (that’s not live yet) a designer has shown a pretty original approach to displaying pagination links and client was immediately convinced that’s the way to go.

The idea was: we present first and last pages as standard links, but between them — instead of the standard three-dot — we put a select with all the pages “between”, of course doing a javascript-powered redirect to the chosen page. Like this:

In terms of business requirements, if the page count is equal to or below 10, we show just the links to the pages. If there are more than 10 pages, we put 5 to the left, 5 to the right and all the excess pages are accessed via a select in the middle. The redirect occurs automatically on select. The current page, if it’s amongst the options in select, is displayed in bold.

The Approach

From the design it seems we need a constant amount of page links on both “sides” of the pagination helper. And (this is an important part of the approach) we need to generate the select in place of gap.

Dive In

That’s where mislav-will_paginate really shines. It can use custom renderers — classes (I put them in app/helpers directory) extending WillPaginate::LinkRenderer with a few methods to (re)implement: in this case we’re going to implement prepare (for assigning our custom gap) and visible_page_numbers (so we always have links on both sides to first and last pages and no links around the gap).

Let’s make it flexible enough so the number “5” (amount of page links to the left and right of select) isn’t hardcoded, just use well-known outer_window option.

In our application we had to reimplement also the to_html method to have some additional fancy stuff on the edges, but that’s not relevant.

Now I know it’s not a very preferred way of learning, but let’s see the code that does the trick:

class FancyRenderer < WillPaginate::LinkRenderer

  def prepare(collection, options, template)
    @gap_marker = options.delete(:gap)
    super
  end
  
  def visible_page_numbers
    inner_window, outer_window = @options[:inner_window].to_i, @options[:outer_window].to_i
    window_from = current_page - inner_window
    window_to = current_page + inner_window
    
    # adjust lower or upper limit if other is out of bounds
    if window_to > total_pages
      window_from -= window_to - total_pages
      window_to = total_pages
    end
    if window_from < 1
      window_to += 1 - window_from
      window_from = 1
      window_to = total_pages if window_to > total_pages
    end
    
    visible = (1..total_pages).to_a #start with all of them...
    
    left_gap = right_gap = (2 + outer_window)...(total_pages - outer_window)
    visible -= left_gap.to_a if left_gap.last - left_gap.first > 1
    visible -= right_gap.to_a if right_gap.last - right_gap.first > 1

    visible
  end
end

It’s pretty obvious from the code (don’t worry, it took me some time to write it) that mislav-will_paginate rendering uses @gap_marker variable and we need to call will_paginate helper with :gap option. If we don’t give it

  will_paginate @items, 
    :inner_window => 1, 
    :outer_window => 4, 
    :renderer => 'FancyRenderer'

it looks like this:

So let’s see if it really displays our custom stuff in place of gap:

  will_paginate @items, 
    :inner_window => 1, 
    :outer_window => 4, 
    :gap => "hello!", 
    :renderer => 'FancyRenderer'

result:

It’s time to write the code that’ll create the select for remaining “between” pages!

We’re not there yet

You know what I like most about Rails? That it’s written in Ruby. And in Ruby you can really do some fancy shit that’s readable and usable at the same time.

We’re going to write a “gap select” generator that’s going to create a required amount of relevant select options with paginated paths as values. But we want to make the code as elasic and reusable as possible (DRY, motherfucker! Do you follow it?), so calling the given resource path generator within our gap-generating helper is not an option.

So… how about something like this, sexy enough?

  will_paginate @items, 
    :inner_window => 1, 
    :outer_window => 4, 
    :gap => page_selector_generator(@items, stripped_params) {|p| items_path(p)}, 
    :renderer => 'FancyRenderer'

The “stripped_params” are here of course for the sake of including all the other GET parameters (see “links for extra parameters” section in my post about Searchlogic), made by cloning params and removing key/value pairs that could interfere with path generator.

For instance:

  stripped_params = params.clone 
  stripped_params.delete(:controller)
  stripped_params.delete(:action)

(You might want to put it in helper.)

So, we have sexy, DRY and cool code ready to reuse. Let’s write the page_selector_generator helper (finally!) to feed it with these stripped_params and path helper block.

module ApplicationHelper
  #accepts a block (one argument: page) with a path-generating helper
  def page_selector_generator(collection, extra={}, first = 'jump to', &block)
    arr = Array.new
    arr << [first,'']

    offset = collection.total_pages - 6

    (5..offset).each do |page|
      page += 1
      arr << [page, block.call(extra.merge({:page => page}))]
    end
    
    content_tag :select, :id => 'page_selector' do
      #the second argument is here for giving current page a "selected" attribute
      options_for_select(arr, block.call(extra.merge({:page => collection.current_page})))
    end
  end
end

And it looks this way:

So… voila!

(Yeah, I know, the “5” left-and-right offset is hardcoded, so I’ll probably refactor this code soon)

Almost there, i.e. proofing the awesome

The above code is great with one exception: if you supply some extra parameters, the path in select option’s value is not generated properly — the ampersands (&) get escaped and thus the paths from value don’t yield what would be expected of them.

We have to write our own variant of options_for_select:

module ApplicationHelper
  def not_escaped_options_for_select(container, selected = nil)
    options_for_select = container.inject([]) do |options, element|
      text, value = option_text_and_value(element)
      selected_attribute = ' selected="selected"' if option_value_selected?(value, selected)
      options << %(<option value="#{value.to_s}"#{selected_attribute}>#{html_escape(text.to_s)}</option>)
    end
    
    options_for_select.join("\n")
  end
end

The above code may be ugly (I never really digged html-generating helpers), but it does the trick: the paths are not entity-escaped and we can finally move onto javascript redirects.

I wanted to end it with “this remains as an exercise for the reader”, but I just got reminded how pissed I always was when encountering such crap: it’s a tech blog article, goddamit. People come here for answers, not for fucking riddles.

Of course we’ll write the javascript the unobtrusive way. Here’s the snippet if you’re using Prototype:

$('page_selector').observe('change', function(e) {
  location = 'http://' + window.location.host + $('page_selector').value;
});

I’ll leave the jQuery version as an exercise for the reader.

Searchlogic + Mislav_Will-Paginate = WIN

Old School

If you’re using Searchlogic 1.6.x, you can easily prepare instance variable in a way digestible for will_paginate helper:

search = Model.new_search(...) # searchlogic scope preparation 
# remember to also do the pagination, i.e. make use of :page and :per_page parameters
models_arr = search.find(:all) # array of elements for current page
@models = WillPaginate::Collection.new(search.page, search.per_page)
@models.replace(models_arr)
@models.total_entries = search.count

And you’re ready to use will_paginage(@models) in your views!

Searchlogic 2.x

New searchlogic exposes “all” method’s result as named scope (and thus works great in named scope chains), so it’s even easier to make it work with model methods added by mislav-will_paginate:

named_scopes = Model.whatever.named.scopes.you.use #named_scope
search = named_scopes.search(params)
@models = search.all.paginate(:page => params[:page], :per_page => 10) 

(thanks to folks, especially gRuby, from polish RubyOnRails forum)

Conclusion

That’s all folks, at least for the Part 2 of Rails Tricks series.

I hope it succeeded in showing that you can do some really fancy stuff with mislav-will_paginate and exploiting the fact we’re writing Ruby.

Of course suggestions and comments are welcome (damn I learned a lot from previous’ part comments!).