module Positionable
# This +acts_as+ extension provides the capabilities for sorting and reordering a number of objects in a list.
# The class that has this specified needs to have a column defined as an integer on the mapped database table
# to store the list's position. The default is +position+.
#
# Todo list example:
#
# class TodoList < ActiveRecord::Base
# has_many :todo_items, :order => "position"
# end
#
# class TodoItem < ActiveRecord::Base
# belongs_to :todo_list
# acts_as_positionable :scope => :todo_list_id
# end
#
# todo_list.first.move_to_bottom
# todo_list.last.move_higher
module ClassMethods
# Configuration options are:
#
# * +column+ - specifies the column name to use for keeping the position integer (default: +position+)
# * +scope+ - restricts what is to be considered a list. Expects a symbol representing a object attribute
# * +conditions+ - activerecord find conditions to evaluate if the record should be inserted on the list
# * +list_name+ - the name for this list's act_as_positionable declaration (default: +:default+)
def acts_as_positionable(options = {})
options = { :list_name => :default, :column => "position" }.merge(options)
cattr_accessor :positionable unless respond_to?(:positionable)
self.positionable ||= Positionable::Wrapper.new
self.positionable << options
named_scope :ascending, lambda { |*list_name|
{ :order => self.positionable.lists[list_name.first || :default][:column] }
}
named_scope :descending, lambda { |*list_name|
{ :order => "#{self.positionable.lists[list_name.first || :default][:column]} DESC" }
}
named_scope :conditions, lambda { |*list_name|
{ :conditions => self.positionable.lists[list_name.first || :default][:conditions] }
}
class_eval <<-EOV
include Positionable::InstanceMethods
def acts_as_positionable_class
::#{self.name}
end
before_create :add_to_lists
before_update :update_lists
before_destroy :remove_from_lists
EOV
end
end
module EvalSupport
def conditions_to_eval(conditions)
case conditions
when Array then array_conditions_to_eval(conditions)
when Hash then hash_conditions_to_eval(conditions)
when String then string_conditions_to_eval(conditions)
end
end
def array_conditions_to_eval(conditions)
string_conditions_to_eval ActiveRecord::Base.send(:sanitize_sql_array, conditions)
end
def hash_conditions_to_eval(conditions)
conditions.inject("") do |str, (attribute, value)|
str << ' && ' unless str.blank?
str << "#{attribute} == "
str << case value
when String then "\'#{value}\'"
when nil then 'nil'
else value.to_s
end
end
end
def string_conditions_to_eval(conditions)
conditions.gsub(' = ', ' == ').gsub(/ and /i, ' && ').gsub(/ or /i, ' || ')
end
module_function :conditions_to_eval, :array_conditions_to_eval, :hash_conditions_to_eval, :string_conditions_to_eval
end
# Wrapper class for all the lists' options
class Wrapper
attr_accessor :lists
def initialize
self.lists = {}
end
def <<(options={})
options[:conditions_to_eval] = Positionable::EvalSupport.conditions_to_eval(options[:conditions])
self.lists[options.delete(:list_name)] = options
end
def has_scope?(list_name)
!!lists[list_name][:scope]
end
def scope(list_name)
lists[list_name][:scope].is_a?(Array) ? lists[list_name][:scope] : [lists[list_name][:scope]]
end
def has_conditions?(list_name)
!!lists[list_name][:conditions]
end
def conditions(list_name)
lists[list_name][:conditions]
end
end
# All the methods available to a record that has had acts_as_positionable specified. Each method works
# by assuming the object to be the item in the list, so chapter.move_lower would move that chapter
# lower in the list of all chapters. Likewise, chapter.first? would return +true+ if that chapter is
# the first in the list of all chapters.
module InstanceMethods
# Insert the item at the given position (defaults to the top position).
def insert_at(position = :top, list_name = :default)
if !position.is_a?(Symbol) && position <= 0
position = :top
elsif !position.is_a?(Symbol) && position > bottom_position_in_list(list_name) && position != 1
position = :bottom
end
insert_at_position(list_name, position)
end
def insert_at_top(list_name = :default)
insert_at_position(list_name, :top)
end
def insert_at_bottom(list_name = :default)
insert_at_position(list_name, :bottom)
end
# Swap positions with the next lower item, if one exists.
def move_lower(list_name = :default)
lower = lower_item
insert_at_position(list_name, lower.list_position) if lower
end
alias_method :move_down, :move_lower
# Swap positions with the next higher item, if one exists.
def move_higher(list_name = :default)
higher = higher_item
insert_at_position(list_name, higher.list_position) if higher
end
alias_method :move_up, :move_higher
# Move to the bottom of the list.
def move_to_bottom(list_name = :default)
insert_at_position(list_name, :bottom) if in_list?
end
# Move to the top of the list.
def move_to_top(list_name = :default)
insert_at_position(list_name, :top) if in_list?
end
# True if the record is the first in the list.
def first?(list_name = :default)
in_list? && list_position == top_position_in_list(list_name)
end
# True if the record is the last in the list.
def last?(list_name = :default)
in_list? && list_position == bottom_position_in_list(list_name)
end
# Returns the next higher item in the list.
def higher_item(list_name = :default)
return nil unless in_list?
scoped(list_name).descending(list_name).first(:conditions => options_for_position(list_name, '<'))
end
alias_method :previous_item, :higher_item
# Returns the next lower item in the list.
def lower_item(list_name = :default)
return nil unless in_list?
scoped(list_name).ascending(list_name).first(:conditions => options_for_position(list_name, '>'))
end
alias_method :next_item, :lower_item
# True if the record is in the list.
def in_list?(list_name = :default)
!list_position(list_name).nil?
end
# Returns the record list position for the list.
def list_position(list_name = :default)
send position_column(list_name)
end
# Removes the record from the list and shift other items accordingly.
def remove_from_list(list_name = :default)
if in_list?
acts_as_positionable_class.transaction do
decrement_positions_on_lower_items(list_name)
update_attribute position_column(list_name), nil
end
end
end
protected
# Flag the record's position on all lists as long as it meets the lists' conditions.
# No save or update is performed.
def add_to_lists
positionable.lists.each_key do |list_name|
if !positionable.has_conditions?(list_name) || meet_conditions?(list_name)
add_to_list_bottom(list_name)
end
end
end
# Flag the record's position on a list as after the current last item.
# No save or update is performed
def add_to_list_bottom(list_name)
self[position_column(list_name)] = bottom_position_in_list(list_name) + 1
end
# Remove the record from all lists.
def remove_from_lists
positionable.lists.each_key do |list_name|
remove_from_list(list_name)
end
end
# Remove and insert the record according to the lists' scopes and conditions.
def update_lists
@old_record = acts_as_positionable_class.find self.id
positionable.lists.each_key do |list_name|
if positionable.has_scope?(list_name) && scope_changed?(list_name)
@old_record.remove_from_list(list_name)
add_to_list_bottom(list_name)
end
if positionable.has_conditions?(list_name) && conditions_changed?(list_name)
if in_list?(list_name)
@old_record.remove_from_list(list_name)
self[position_column(list_name)] = nil
else
add_to_list_bottom(list_name)
end
end
end
end
# True if this save action changed the record according to the list's scope.
def scope_changed?(list_name)
positionable.scope(list_name).each do |scope|
return true if @old_record.send(scope) != self.send(scope)
end
false
end
# True if this save action changed the record according to the list's conditions.
def conditions_changed?(list_name)
@old_record.meet_conditions?(list_name) != self.meet_conditions?(list_name)
end
# True if the record meets the list conditions.
def meet_conditions?(list_name)
eval positionable.lists[list_name][:conditions_to_eval]
end
# Returns an activerecord class scope, based on the list's scope and conditions.
def scoped(list_name)
chain = acts_as_positionable_class
if positionable.has_scope?(list_name)
positionable.scope(list_name).each { |scope| chain = chain.send("scoped_by_#{scope}", send(scope)) }
end
chain = chain.send(:conditions, list_name) if positionable.has_conditions?(list_name)
chain
end
# Returns the position for the first item in list.
# Lists with gaps might have a number other than 1
def top_position_in_list(list_name)
top_item(list_name).try(:list_position, list_name) || 1
end
alias_method :first_position_in_list, :top_position_in_list
# Returns the first item of the list.
def top_item(list_name)
scoped(list_name).ascending(list_name).first(:conditions => options_for_position(list_name, :not_null))
end
# Returns the position for the bottom item in the list.
def bottom_position_in_list(list_name)
bottom_item(list_name).try(:list_position, list_name) || 0
end
alias_method :last_position_in_list, :bottom_position_in_list
# Returns the bottom item.
def bottom_item(list_name)
scoped(list_name).descending(list_name).first(:conditions => options_for_position(list_name, :not_null))
end
# Returns the attribute name for the column storing the list position.
def position_column(list_name = :default)
positionable.lists[list_name][:column]
end
# Builds position options for updates.
def options_for_position(list_name, option, value = nil)
return "#{position_column(list_name)} IS NOT NULL" if option == :not_null
"#{position_column(list_name)} #{option} #{value || list_position(list_name)}"
end
# Builds position assignment statement for updates.
def update_options(list_name, sign)
"#{position_column(list_name)} = (#{position_column(list_name)} #{sign} 1)"
end
# Move all the lower items up one position.
def decrement_positions_on_lower_items(list_name, limit = nil)
conditions = options_for_position(list_name, '>')
conditions << " AND " << options_for_position(list_name, '<=', limit) if limit
scoped(list_name).update_all(update_options(list_name, '-'), conditions)
end
# Move all items lower than the given position down one position.
def increment_positions_on_lower_items(list_name, position)
scoped(list_name).update_all(update_options(list_name, '+'), options_for_position(list_name, '>=', position))
end
# Move all the higher items up one position.
def increment_positions_on_higher_items(list_name, limit = nil)
conditions = options_for_position(list_name, '<')
conditions << " AND " << options_for_position(list_name, '>=', limit) if limit
scoped(list_name).update_all(update_options(list_name, '+'), conditions)
end
# Insert at the given position and shifts all necessary items accordingly.
def insert_at_position(list_name, position)
if position.is_a?(Symbol)
position = send("#{position}_position_in_list", list_name)
position = 1 if position == 0
end
acts_as_positionable_class.transaction do
if in_list?
if position < self.list_position
increment_positions_on_higher_items(list_name, position)
elsif position > self.list_position
decrement_positions_on_lower_items(list_name, position)
end
else
increment_positions_on_lower_items(list_name, position)
end
self.update_attribute(position_column(list_name), position)
end
end
end
end
ActiveRecord::Base.extend Positionable::ClassMethods