module Pagination
# = Pagination view helpers
#
# The main view helper, #paginate, renders
# pagination links around (top and bottom) given collection. The helper itself is lightweight
# and serves only as a wrapper around LinkRenderer instantiation; the
# renderer then does all the hard work of generating the HTML.
#
# == Global options for helpers
#
# Options for pagination helpers are optional and get their default values from the
# Pagination::ViewHelpers.pagination_options hash. You can write to this hash to
# override default options on the global level:
#
# Pagination::ViewHelpers.pagination_options[:previous_label] = 'Previous page'
#
# By putting this into "config/initializers/pagination.rb" (or simply environment.rb in
# older versions of Rails) you can easily translate link texts to previous
# and next pages, as well as override some other defaults to your liking.
module ViewHelpers
# default options that can be overridden on the global level
@@pagination_options = {
:class => 'pagination',
:previous_label => '« Previous',
:next_label => 'Next »',
:inner_window => 4, # links around the current page
:outer_window => 1, # links around beginning and end
:separator => ' ', # single space is friendly to spiders and non-graphic browsers
:controls => :both,
:per_page => 10,
:param_name => :page,
:params => nil,
:renderer => 'Pagination::LinkRenderer',
:page_links => true,
:container => true
}
mattr_reader :pagination_options
# Renders Digg/Flickr-style pagination for a Pagination::Collection
# object. Nil is returned if there is only one page in total; no point in
# rendering the pagination in that case...
#
# ==== Options
# Display options:
# * :previous_label -- default: "« Previous" (this parameter is called :prev_label in versions 2.3.2 and older!)
# * :next_label -- default: "Next »"
# * :page_links -- when false, only previous/next links are rendered (default: true)
# * :inner_window -- how many links are shown around the current page (default: 4)
# * :outer_window -- how many links are around the first and the last page (default: 1)
# * :separator -- string separator for page HTML elements (default: single space)
# * :controls -- display controls only at the :top or :bottom of the pagination block (default: :both)
# * :per_page -- number of items displayed per page (default: 10)
#
# HTML options:
# * :class -- CSS class name for the generated DIV (default: "pagination")
# * :container -- toggles rendering of the DIV container for pagination links, set to
# false only when you are rendering your own pagination markup (default: true)
# * :id -- HTML ID for the container (default: nil). Pass +true+ to have the ID
# automatically generated from the class name of objects in collection: for example, paginating
# ArticleComment models would yield an ID of "article_comments_pagination".
#
# Advanced options:
# * :param_name -- parameter name for page number in URLs (default: :page)
# * :params -- additional parameters when generating pagination links
# (eg. :controller => "foo", :action => nil)
# * :renderer -- class name, class or instance of a link renderer (default:
# Pagination::LinkRenderer)
#
# All options not recognized by paginate will become HTML attributes on the container
# element for pagination links (the DIV). For example:
#
# <% paginate @posts, :style => 'font-size: small' do |posts| %>
# ...
# <% end %>
#
# ... will result in:
#
#
#
def paginate(collection = [], options = {}, &block)
options = options.symbolize_keys.reverse_merge Pagination::ViewHelpers.pagination_options
collection = Pagination::Collection.new(options.merge(:collection => collection, :current_page => params[options[:param_name]]|| 1))
# get the renderer instance
renderer = case options[:renderer]
when String
options[:renderer].to_s.constantize.new
when Class
options[:renderer].new
else
options[:renderer]
end
# render HTML for pagination
renderer.prepare collection, options, self
pagination = renderer.to_html.to_s
if block_given?
yield collection and return nil unless collection.total_pages > 1
top = [:top, :both].include?(options[:controls]) ? pagination : ""
bottom = [:bottom, :both].include?(options[:controls]) ? pagination : ""
unless ActionView::Base.respond_to? :erb_variable
concat top
yield collection
unless bottom.empty?
concat bottom
end
else
content = top + capture(&block) + bottom
concat(content, block.binding)
end
else
collection
end
end
end
# This class does the heavy lifting of actually building the pagination
# links. It is used by the paginate helper internally.
class LinkRenderer
# The gap in page links is represented by:
#
# …
attr_accessor :gap_marker
def initialize
@gap_marker = '…'
end
# * +collection+ is a Pagination::Collection instance or any other object
# that conforms to that API
# * +options+ are forwarded from +paginate+ view helper
# * +template+ is the reference to the template being rendered
def prepare(collection, options, template)
@collection = collection
@options = options
@template = template
# reset values in case we're re-using this instance
@total_pages = @param_name = @url_string = nil
end
# Process it! This method returns the complete HTML string which contains
# pagination links. Feel free to subclass LinkRenderer and change this
# method as you see fit.
def to_html
links = @options[:page_links] ? windowed_links : []
# previous/next buttons
links.unshift page_link_or_span(@collection.previous_page, 'disabled prev_page', @options[:previous_label])
links.push page_link_or_span(@collection.next_page, 'disabled next_page', @options[:next_label])
html = links.join(@options[:separator]).html_safe
@options[:container] ? @template.content_tag(:div, html, html_attributes) : html
end
# Returns the subset of +options+ this instance was initialized with that
# represent HTML attributes for the container element of pagination links.
def html_attributes
return @html_attributes if @html_attributes
@html_attributes = @options.except *(Pagination::ViewHelpers.pagination_options.keys - [:class])
# pagination of Post models will have the ID of "posts_pagination"
if @options[:container] and @options[:id] === true
@html_attributes[:id] = @collection.first.class.name.underscore.pluralize + '_pagination'
end
@html_attributes
end
protected
# Collects link items for visible page numbers.
def windowed_links
prev = nil
visible_page_numbers.inject [] do |links, n|
# detect gaps:
links << gap_marker if prev and n > prev + 1
links << page_link_or_span(n, 'current')
prev = n
links
end
end
# Calculates visible page numbers using the :inner_window and
# :outer_window options.
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
left_gap = (2 + outer_window)...window_from
right_gap = (window_to + 1)...(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
def page_link_or_span(page, span_class, text = nil)
text ||= page.to_s
if page and page != current_page
classnames = span_class && span_class.index(' ') && span_class.split(' ', 2).last
page_link page, text, :rel => rel_value(page), :class => classnames
else
page_span page, text, :class => span_class
end
end
def page_link(page, text, attributes = {})
@template.link_to text, url_for(page), attributes
end
def page_span(page, text, attributes = {})
@template.content_tag :span, text, attributes
end
# Returns URL params for +page_link_or_span+, taking the current GET params
# and :params option into account.
def url_for(page)
page_one = page == 1
unless @url_string and !page_one
@url_params = {}
# page links should preserve GET parameters
stringified_merge @url_params, @template.params if @template.request.get?
stringified_merge @url_params, @options[:params] if @options[:params]
if complex = param_name.index(/[^\w-]/)
page_param = parse_query_parameters("#{param_name}=#{page}")
stringified_merge @url_params, page_param
else
@url_params[param_name] = page_one ? 1 : 2
end
url = @template.url_for(@url_params)
return url if page_one
if complex
@url_string = url.sub(%r!((?:\?|&)#{CGI.escape param_name}=)#{page}!, "\\1\0")
return url
else
@url_string = url
@url_params[param_name] = 3
@template.url_for(@url_params).split(//).each_with_index do |char, i|
if char == '3' and url[i, 1] == '2'
@url_string[i] = "\0"
break
end
end
end
end
# finally!
@url_string.sub "\0", page.to_s
end
private
def rel_value(page)
case page
when @collection.previous_page; 'prev' + (page == 1 ? ' start' : '')
when @collection.next_page; 'next'
when 1; 'start'
end
end
def current_page
@current_page ||= @collection.current_page
end
def total_pages
@total_pages ||= @collection.total_pages
end
def param_name
@param_name ||= @options[:param_name].to_s
end
# Recursively merge into target hash by using stringified keys from the other one
def stringified_merge(target, other)
other.each do |key, value|
key = key.to_s # this line is what it's all about!
existing = target[key]
if value.is_a?(Hash) and (existing.is_a?(Hash) or existing.nil?)
stringified_merge(existing || (target[key] = {}), value)
else
target[key] = value
end
end
end
def parse_query_parameters(params)
if defined? Rack::Utils
# For Rails > 2.3
Rack::Utils.parse_nested_query(params)
elsif defined?(ActionController::AbstractRequest)
ActionController::AbstractRequest.parse_query_parameters(params)
elsif defined?(ActionController::UrlEncodedPairParser)
# For Rails > 2.2
ActionController::UrlEncodedPairParser.parse_query_parameters(params)
elsif defined?(CGIMethods)
CGIMethods.parse_query_parameters(params)
else
raise "unsupported ActionPack version"
end
end
end
end