aboutsummaryrefslogtreecommitdiffstats
path: root/activerecord/lib/active_record/acts/list.rb
blob: 425ac3856d356388b8bf028576cbcab3e6fdaab0 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
module ActiveRecord
  module Acts #:nodoc:
    module List #:nodoc:
      def self.append_features(base)
        super
        base.extend(ClassMethods)
      end
      
      # This act provides the capabilities for sorting and reordering a number of objects in 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 that hasn't been already) 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: <tt>acts_as_list :scope => 'todo_list_id = #{todo_list_id} AND completed = 0'</tt>
        def acts_as_list(options = {})
          configuration = { :column => "position", :scope => "1 = 1" }
          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_condition_method = %(
              def scope_condition
                if #{configuration[:scope].to_s}.nil?
                  "#{configuration[:scope].to_s} IS NULL"
                else
                  "#{configuration[:scope].to_s} = \#{#{configuration[:scope].to_s}}"
                end
              end
            )
          else
            scope_condition_method = "def scope_condition() \"#{configuration[:scope]}\" end"
          end
          
          class_eval <<-EOV
            include ActiveRecord::Acts::List::InstanceMethods

            def position_column
              '#{configuration[:column]}'
            end
            
            #{scope_condition_method}
            
            after_destroy  :remove_from_list
            before_create  :add_to_list_bottom
          EOV
        end
      end
        
      # All the methods available to a record that has had <tt>acts_as_list</tt> specified. Each method works
      # by assuming the object to be the item in the list, so <tt>chapter.move_lower</tt> would move that chapter
      # lower in the list of all chapters. Likewise, <tt>chapter.first?</tt> would return true if that chapter is
      # the first in the list of all chapters.
      module InstanceMethods
        def insert_at(position = 1)
          insert_at_position(position)
        end

        def move_lower
          return unless lower_item

          self.class.transaction do
            lower_item.decrement_position
            increment_position
          end
        end
        
        def move_higher
          return unless higher_item

          self.class.transaction do
            higher_item.increment_position
            decrement_position
          end
        end
        
        def move_to_bottom
          return unless in_list?
          self.class.transaction do
            decrement_positions_on_lower_items
            assume_bottom_position
          end
        end
        
        def move_to_top
          return unless in_list?
          self.class.transaction do
            increment_positions_on_higher_items
            assume_top_position
          end
        end
  
        def remove_from_list
          decrement_positions_on_lower_items if in_list?
        end

        def increment_position
          return unless in_list?
          update_attribute position_column, self.send(position_column).to_i + 1
        end
  
        def decrement_position
          return unless in_list?
          update_attribute position_column, self.send(position_column).to_i - 1
        end
  
        def first?
          return false unless in_list?
          self.send(position_column) == 1
        end
        
        def last?
          return false unless in_list?
          self.send(position_column) == bottom_position_in_list
        end
        
        def higher_item
          return nil unless in_list?
          self.class.find(:first, :conditions =>
            "#{scope_condition} AND #{position_column} = #{(send(position_column).to_i - 1).to_s}"
          )
        end

        def lower_item
          return nil unless in_list?
          self.class.find(:first, :conditions =>
            "#{scope_condition} AND #{position_column} = #{(send(position_column).to_i + 1).to_s}"
          )
        end

        def in_list?
          !send(position_column).nil?
        end

        private
          def add_to_list_top
            increment_positions_on_all_items
          end

          def add_to_list_bottom
            self[position_column] = bottom_position_in_list.to_i + 1
          end

          # Overwrite this method to define the scope of the list changes
          def scope_condition() "1" end

          def bottom_position_in_list(except = nil)
            item = bottom_item(except)
            item ? item.send(position_column) : 0
          end

          def bottom_item(except = nil)
            conditions = scope_condition
            conditions = "#{conditions} AND id != #{except.id}" if except
            self.class.find(:first, :conditions => conditions, :order => "#{position_column} DESC")
          end

          def assume_bottom_position
            update_attribute(position_column, bottom_position_in_list(self).to_i + 1)
          end
  
          def assume_top_position
            update_attribute(position_column, 1)
          end

          # This has the effect of moving all the higher items up one.
          def decrement_positions_on_higher_items(position)
            self.class.update_all(
              "#{position_column} = (#{position_column} - 1)", "#{scope_condition} AND #{position_column} <= #{position}"
            )
          end

          # This has the effect of moving all the lower items up one.
          def decrement_positions_on_lower_items
            return unless in_list?
            self.class.update_all(
              "#{position_column} = (#{position_column} - 1)", "#{scope_condition} AND #{position_column} > #{send(position_column).to_i}"
            )
          end

          # This has the effect of moving all the higher items down one.
          def increment_positions_on_higher_items
            return unless in_list?
            self.class.update_all(
              "#{position_column} = (#{position_column} + 1)", "#{scope_condition} AND #{position_column} < #{send(position_column).to_i}"
            )
          end

          # This has the effect of moving all the lower items down one.
          def increment_positions_on_lower_items(position)
            self.class.update_all(
              "#{position_column} = (#{position_column} + 1)", "#{scope_condition} AND #{position_column} >= #{position}"
           )
          end

          def increment_positions_on_all_items
            self.class.update_all(
              "#{position_column} = (#{position_column} + 1)",  "#{scope_condition}"
            )
          end

          def insert_at_position(position)
            remove_from_list
            increment_positions_on_lower_items(position)
            self.update_attribute(position_column, position)
          end
      end     
    end
  end
end