module ActiveRecord
module Acts #:nodoc:
module List #:nodoc:
def self.included(base)
base.extend(ClassMethods)
end
# 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 +position+ column defined as an integer on
# the mapped database table.
#
# Todo list example:
#
# class TodoList < ActiveRecord::Base
# has_many :todo_items, order: "position"
# end
#
# class TodoItem < ActiveRecord::Base
# belongs_to :todo_list
# acts_as_list scope: :todo_list
# 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. Given a symbol, it'll attach _id
# (if it hasn't already been added) and use that as the foreign key restriction. It's also possible
# to give it an entire string that is interpolated if you need a tighter scope than just a foreign key.
# Example: acts_as_list scope: 'todo_list_id = #{todo_list_id} AND completed = 0'
# * +top_of_list+ - defines the integer used for the top of the list. Defaults to 1. Use 0 to make the collection
# act more like an array in its indexing.
# * +add_new_at+ - specifies whether objects get added to the :top or :bottom of the list. (default: +bottom+)
# `nil` will result in new items not being added to the list on create
def acts_as_list(options = {})
configuration = { column: "position", scope: "1 = 1", top_of_list: 1, add_new_at: :bottom}
configuration.update(options) if options.is_a?(Hash)
configuration[:scope] = "#{configuration[:scope]}_id".intern if configuration[:scope].is_a?(Symbol) && configuration[:scope].to_s !~ /_id$/
if configuration[:scope].is_a?(Symbol)
scope_methods = %(
def scope_condition
{ :#{configuration[:scope].to_s} => send(:#{configuration[:scope].to_s}) }
end
def scope_changed?
changes.include?(scope_name.to_s)
end
)
elsif configuration[:scope].is_a?(Array)
scope_methods = %(
def attrs
%w(#{configuration[:scope].join(" ")}).inject({}) do |memo,column|
memo[column.intern] = read_attribute(column.intern); memo
end
end
def scope_changed?
(attrs.keys & changes.keys.map(&:to_sym)).any?
end
def scope_condition
attrs
end
)
else
scope_methods = %(
def scope_condition
"#{configuration[:scope]}"
end
def scope_changed?() false end
)
end
class_eval <<-EOV
include ::ActiveRecord::Acts::List::InstanceMethods
def acts_as_list_top
#{configuration[:top_of_list]}.to_i
end
def acts_as_list_class
::#{self.name}
end
def position_column
'#{configuration[:column]}'
end
def scope_name
'#{configuration[:scope]}'
end
def add_new_at
'#{configuration[:add_new_at]}'
end
def #{configuration[:column]}=(position)
write_attribute(:#{configuration[:column]}, position)
@position_changed = true
end
#{scope_methods}
# only add to attr_accessible
# if the class has some mass_assignment_protection
if defined?(accessible_attributes) and !accessible_attributes.blank?
attr_accessible :#{configuration[:column]}
end
before_destroy :reload_position
after_destroy :decrement_positions_on_lower_items
before_update :check_scope
after_update :update_positions
before_validation :check_top_position
scope :in_list, lambda { where("#{table_name}.#{configuration[:column]} IS NOT NULL") }
EOV
if configuration[:add_new_at].present?
self.send(:before_create, "add_to_list_#{configuration[:add_new_at]}")
end
end
end
# All the methods available to a record that has had acts_as_list 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 of 1).
def insert_at(position = acts_as_list_top)
insert_at_position(position)
end
# Swap positions with the next lower item, if one exists.
def move_lower
return unless lower_item
acts_as_list_class.transaction do
lower_item.decrement_position
increment_position
end
end
# Swap positions with the next higher item, if one exists.
def move_higher
return unless higher_item
acts_as_list_class.transaction do
higher_item.increment_position
decrement_position
end
end
# Move to the bottom of the list. If the item is already in the list, the items below it have their
# position adjusted accordingly.
def move_to_bottom
return unless in_list?
acts_as_list_class.transaction do
decrement_positions_on_lower_items
assume_bottom_position
end
end
# Move to the top of the list. If the item is already in the list, the items above it have their
# position adjusted accordingly.
def move_to_top
return unless in_list?
acts_as_list_class.transaction do
increment_positions_on_higher_items
assume_top_position
end
end
# Removes the item from the list.
def remove_from_list
if in_list?
decrement_positions_on_lower_items
set_list_position(nil)
end
end
# Move the item within scope. If a position within the new scope isn't supplied, the item will
# be appended to the end of the list.
def move_within_scope(scope_id)
send("#{scope_name}=", scope_id)
save!
end
# Increase the position of this item without adjusting the rest of the list.
def increment_position
return unless in_list?
set_list_position(self.send(position_column).to_i + 1)
end
# Decrease the position of this item without adjusting the rest of the list.
def decrement_position
return unless in_list?
set_list_position(self.send(position_column).to_i - 1)
end
# Return +true+ if this object is the first in the list.
def first?
return false unless in_list?
self.send(position_column) == acts_as_list_top
end
# Return +true+ if this object is the last in the list.
def last?
return false unless in_list?
self.send(position_column) == bottom_position_in_list
end
# Return the next higher item in the list.
def higher_item
return nil unless in_list?
acts_as_list_class.unscoped do
acts_as_list_class.where(scope_condition).where("#{position_column} < #{(send(position_column).to_i).to_s}").
order("#{acts_as_list_class.table_name}.#{position_column} DESC").first
end
end
# Return the next n higher items in the list
# selects all higher items by default
def higher_items(limit=nil)
limit ||= acts_as_list_list.count
position_value = send(position_column)
acts_as_list_list.
where("#{position_column} < ?", position_value).
where("#{position_column} >= ?", position_value - limit).
limit(limit).
order("#{acts_as_list_class.table_name}.#{position_column} ASC")
end
# Return the next lower item in the list.
def lower_item
return nil unless in_list?
acts_as_list_class.unscoped do
acts_as_list_class.where(scope_condition).where("#{position_column} > #{(send(position_column).to_i).to_s}").
order("#{acts_as_list_class.table_name}.#{position_column} ASC").first
end
end
# Return the next n lower items in the list
# selects all lower items by default
def lower_items(limit=nil)
limit ||= acts_as_list_list.count
position_value = send(position_column)
acts_as_list_list.
where("#{position_column} > ?", position_value).
where("#{position_column} <= ?", position_value + limit).
limit(limit).
order("#{acts_as_list_class.table_name}.#{position_column} ASC")
end
# Test if this record is in a list
def in_list?
!not_in_list?
end
def not_in_list?
send(position_column).nil?
end
def default_position
acts_as_list_class.columns_hash[position_column.to_s].default
end
def default_position?
default_position && default_position.to_i == send(position_column)
end
# Sets the new position and saves it
def set_list_position(new_position)
write_attribute position_column, new_position
save(validate: false)
end
private
def acts_as_list_list
acts_as_list_class.unscoped do
acts_as_list_class.where(scope_condition)
end
end
def add_to_list_top
increment_positions_on_all_items
self[position_column] = acts_as_list_top
end
def add_to_list_bottom
if not_in_list? || scope_changed? && !@position_changed || default_position?
self[position_column] = bottom_position_in_list.to_i + 1
else
increment_positions_on_lower_items(self[position_column], id)
end
end
# Overwrite this method to define the scope of the list changes
def scope_condition() {} end
# Returns the bottom position number in the list.
# bottom_position_in_list # => 2
def bottom_position_in_list(except = nil)
item = bottom_item(except)
item ? item.send(position_column) : acts_as_list_top - 1
end
# Returns the bottom item
def bottom_item(except = nil)
conditions = scope_condition
conditions = except ? "#{self.class.primary_key} != #{self.class.connection.quote(except.id)}" : {}
acts_as_list_class.unscoped do
acts_as_list_class.in_list.where(scope_condition).where(conditions).order("#{acts_as_list_class.table_name}.#{position_column} DESC").first
end
end
# Forces item to assume the bottom position in the list.
def assume_bottom_position
set_list_position(bottom_position_in_list(self).to_i + 1)
end
# Forces item to assume the top position in the list.
def assume_top_position
set_list_position(acts_as_list_top)
end
# This has the effect of moving all the higher items up one.
def decrement_positions_on_higher_items(position)
acts_as_list_class.unscoped do
acts_as_list_class.where(scope_condition).where(
"#{position_column} <= #{position}"
).update_all(
"#{position_column} = (#{position_column} - 1)"
)
end
end
# This has the effect of moving all the lower items up one.
def decrement_positions_on_lower_items(position=nil)
return unless in_list?
position ||= send(position_column).to_i
acts_as_list_class.unscoped do
acts_as_list_class.where(scope_condition).where(
"#{position_column} > #{position}"
).update_all(
"#{position_column} = (#{position_column} - 1)"
)
end
end
# This has the effect of moving all the higher items down one.
def increment_positions_on_higher_items
return unless in_list?
acts_as_list_class.unscoped do
acts_as_list_class.where(scope_condition).where(
"#{position_column} < #{send(position_column).to_i}"
).update_all(
"#{position_column} = (#{position_column} + 1)"
)
end
end
# This has the effect of moving all the lower items down one.
def increment_positions_on_lower_items(position, avoid_id = nil)
avoid_id_condition = avoid_id ? " AND #{self.class.primary_key} != #{self.class.connection.quote(avoid_id)}" : ''
acts_as_list_class.unscoped do
acts_as_list_class.where(scope_condition).where(
"#{position_column} >= #{position}#{avoid_id_condition}"
).update_all(
"#{position_column} = (#{position_column} + 1)"
)
end
end
# Increments position (position_column) of all items in the list.
def increment_positions_on_all_items
acts_as_list_class.unscoped do
acts_as_list_class.where(
scope_condition
).update_all(
"#{position_column} = (#{position_column} + 1)"
)
end
end
# Reorders intermediate items to support moving an item from old_position to new_position.
def shuffle_positions_on_intermediate_items(old_position, new_position, avoid_id = nil)
return if old_position == new_position
avoid_id_condition = avoid_id ? " AND #{self.class.primary_key} != #{self.class.connection.quote(avoid_id)}" : ''
if old_position < new_position
# Decrement position of intermediate items
#
# e.g., if moving an item from 2 to 5,
# move [3, 4, 5] to [2, 3, 4]
acts_as_list_class.unscoped do
acts_as_list_class.where(scope_condition).where(
"#{position_column} > #{old_position}"
).where(
"#{position_column} <= #{new_position}#{avoid_id_condition}"
).update_all(
"#{position_column} = (#{position_column} - 1)"
)
end
else
# Increment position of intermediate items
#
# e.g., if moving an item from 5 to 2,
# move [2, 3, 4] to [3, 4, 5]
acts_as_list_class.unscoped do
acts_as_list_class.where(scope_condition).where(
"#{position_column} >= #{new_position}"
).where(
"#{position_column} < #{old_position}#{avoid_id_condition}"
).update_all(
"#{position_column} = (#{position_column} + 1)"
)
end
end
end
def insert_at_position(position)
return set_list_position(position) if new_record?
if in_list?
old_position = send(position_column).to_i
return if position == old_position
shuffle_positions_on_intermediate_items(old_position, position)
else
increment_positions_on_lower_items(position)
end
set_list_position(position)
end
# used by insert_at_position instead of remove_from_list, as postgresql raises error if position_column has non-null constraint
def store_at_0
if in_list?
old_position = send(position_column).to_i
set_list_position(0)
decrement_positions_on_lower_items(old_position)
end
end
def update_positions
old_position = send("#{position_column}_was").to_i
new_position = send(position_column).to_i
return unless acts_as_list_class.unscoped do
acts_as_list_class.where(scope_condition).where("#{position_column} = #{new_position}").count > 1
end
shuffle_positions_on_intermediate_items old_position, new_position, id
end
# Temporarily swap changes attributes with current attributes
def swap_changed_attributes
@changed_attributes.each { |k, _| @changed_attributes[k], self[k] =
self[k], @changed_attributes[k] }
end
def check_scope
if scope_changed?
swap_changed_attributes
send('decrement_positions_on_lower_items') if lower_item
swap_changed_attributes
send("add_to_list_#{add_new_at}")
end
end
def reload_position
self.reload
end
# This check is skipped if the position is currently the default position from the table
# as modifying the default position on creation is handled elsewhere
def check_top_position
if send(position_column) && !default_position? && send(position_column) < acts_as_list_top
self[position_column] = acts_as_list_top
end
end
end
end
end
end