module Handles #:nodoc:
# == Overview
#
# A sortable columns feature for your controller and views.
#
# == Basic Usage
#
# Activate the feature in your controller class:
#
# class MyController < ApplicationController
# handles_sortable_columns
# ...
#
# In a view, mark up sortable columns by using the sortable_column helper:
#
# <%= sortable_column "Product" %>
# <%= sortable_column "Price" % >
#
# In controller action, fetch and use the order clause according to current state of sortable columns:
#
# def index
# order = sortable_column_order
# @records = Article.all(:order => order)
# end
#
# That's it for basic usage. Production usage may require passing additional parameters to listed methods.
#
# See also:
# * MetaClassMethods#handles_sortable_columns
# * HelperMethods#sortable_column
# * InstanceMethods#sortable_column_order
module SortableColumns
def self.included(owner)
owner.extend MetaClassMethods
end
# Sortable columns configuration object. Passed to block when you do a:
#
# handles_sortable_column do |conf|
# ...
# end
class Config
# CSS class for link (regardless of sorted state). Default:
#
# nil
attr_accessor :class
# GET parameter name for page number. Default:
#
# page
attr_accessor :page_param
# GET parameter name for sort column and direction. Default:
#
# sort
attr_accessor :sort_param
# Sort indicator text. If any of values are empty, indicator is not displayed. Default:
#
# {:asc => " ↓ ", :desc => " ↑ "}
attr_accessor :indicator_text
# Sort indicator class. Default:
#
# {:asc => "SortedAsc", :desc => "SortedDesc"}
attr_accessor :indicator_class
def initialize(attrs = {})
defaults = {
:page_param => "page",
:sort_param => "sort",
:indicator_text => {:asc => " ↓ ", :desc => " ↑ "},
:indicator_class => {:asc => "SortedAsc", :desc => "SortedDesc"},
}
defaults.merge(attrs).each {|k, v| send("#{k}=", v)}
end
def [](key)
send(key)
end
def []=(key, value)
send("#{key}=", value)
end
end # Config
module MetaClassMethods
# Activate and optionally configure the sortable columns.
#
# class MyController < ApplicationController
# handles_sortable_columns
# end
#
# With configuration:
#
# class MyController < ApplicationController
# handles_sortable_columns do |conf|
# conf.sort_param = "s"
# conf.page_param = "p"
# conf.indicator_text = {}
# ...
# end
# end
#
# conf is a Config object.
def handles_sortable_columns(&block)
# Multiple activation protection.
if not self < InstanceMethods
extend ClassMethods
include InstanceMethods
helper HelperMethods
end
# Configuration is processed at every activation.
yield(sortable_columns_config) if block
end
end # MetaClassMethods
module ClassMethods
# Internal/advanced use only. Access/initialize the sortable columns config.
def sortable_columns_config
# NOTE: This is controller's class instance variable.
@sortable_columns_config ||= ::Handles::SortableColumns::Config.new
end
# Internal/advanced use only. Convert title to sortable column name.
#
# sortable_column_name_from_title("ProductName") # => "product_name"
def sortable_column_name_from_title(title)
title.gsub(/(\s)(\S)/) {$2.upcase}.underscore
end
# Internal/advanced use only. Parse sortable column sort param into a Hash with predefined keys.
#
# parse_sortable_column_sort_param("name") # => {:column => "name", :direction => :asc}
# parse_sortable_column_sort_param("-name") # => {:column => "name", :direction => :desc}
# parse_sortable_column_sort_param("") # => {:column => nil, :direction => nil}
def parse_sortable_column_sort_param(sort)
out = {:column => nil, :direction => nil}
if sort.to_s.strip.match /\A((?:-|))([^-]+)\z/
out[:direction] = $1.empty?? :asc : :desc
out[:column] = $2.strip
end
out
end
end # ClassMethods
module InstanceMethods
protected
# Compile SQL order clause according to current state of sortable columns.
#
# Basic (kickstart) usage:
#
# order = sortable_column_order
#
# WARNING! Basic usage is not recommended for production since it is potentially
# vulnerable to SQL injection!
#
# Production usage with multiple sort criteria, column name validation and defaults:
#
# order = sortable_column_order do |column, direction|
# case column
# when "name"
# "#{column} #{direction}"
# when "created_at", "updated_at"
# "#{column} #{direction}, name ASC"
# else
# "name ASC"
# end
# end
#
# Apply order:
#
# @records = Article.all(:order => order) # Rails 2.x.
# @records = Article.order(order) # Rails 3.
def sortable_column_order(&block)
conf = {}
conf[k = :sort_param] = self.class.sortable_columns_config[k]
# Parse sort param.
pp = self.class.parse_sortable_column_sort_param(params[conf[:sort_param]])
order = if block
yield(pp[:column], pp[:direction])
else
# No block -- do a straight mapping.
if pp[:column]
[pp[:column], pp[:direction]].join(" ")
end
end
# Can be nil.
order
end
end # InstanceMethods
module HelperMethods
# Render a sortable column link.
#
# Options:
# * :column -- Column name. E.g. "created_at".
# * :direction -- Sort direction on first click. :asc or :desc. Default is :asc.
# * :class -- CSS class for link (regardless of sorted state).
# * :style -- CSS style for link (regardless of sorted state).
#
# Examples:
#
# <%= sortable_column "Product" %>
# <%= sortable_column "Highest Price", :column_name => "max_price" %>
# <%= sortable_column "Name", :class => "SortableLink" %>
# <%= sortable_column "Created At", :direction => :asc %>
def sortable_column(title, options = {})
options = options.dup
o = {}
conf = {}
conf[k = :sort_param] = controller.class.sortable_columns_config[k]
conf[k = :page_param] = controller.class.sortable_columns_config[k]
conf[k = :indicator_text] = controller.class.sortable_columns_config[k]
conf[k = :indicator_class] = controller.class.sortable_columns_config[k]
#HELP sortable_column
o[k = :column] = options.delete(k) || controller.class.sortable_column_name_from_title(title)
o[k = :direction] = options.delete(k).to_s.downcase =~ /\Adesc\z/ ? :desc : :asc
o[k = :class] = options.delete(k) || controller.class.sortable_columns_config[k]
o[k = :style] = options.delete(k)
#HELP /sortable_column
raise "Unknown option(s): #{options.inspect}" if not options.empty?
# Parse sort param.
pp = controller.class.parse_sortable_column_sort_param(params[conf[:sort_param]])
css_class = []
if (s = o[:class]).present?
css_class << s
end
# If already sorted and indicator class defined, append it.
if pp[:column] == o[:column].to_s and (s = conf[:indicator_class][pp[:direction]]).present?
css_class << s
end
# Build link itself.
pcs = []
html_options = {}
html_options[:class] = css_class.join(" ") if css_class.present?
html_options[:style] = o[:style] if o[:style].present?
# Already sorted?
if pp[:column] == o[:column].to_s
pcs << link_to(title, params.merge({conf[:sort_param] => [("-" if pp[:direction] == :asc), o[:column]].join, conf[:page_param] => 1}), html_options) # Opposite sort order when clicked.
# Append indicator, if configured.
if (s = conf[:indicator_text][pp[:direction]]).present?
pcs << s
end
else
# Not sorted.
pcs << link_to(title, params.merge({conf[:sort_param] => [("-" if o[:direction] != :asc), o[:column]].join, conf[:page_param] => 1}), html_options)
end
# For Rails 3 provide #html_safe.
(v = pcs.join).respond_to?(:html_safe) ? v.html_safe : v
end
end # HelperMethods
end # SortableColumns
end # Handles