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