require 'dm-core' require 'dm-adjust' module DataMapper module Is # = dm-is-list # # DataMapper plugin for creating and organizing lists. # # == Installation # # === Stable # # Install the +dm-is-list+ gem using rubygems. # # $ gem install dm-is-list # # === Edge # # Download or clone +dm-is-list+ from Github[http://github.com/datamapper/dm-is-list/]. # # $ cd /path/to/dm-is-list # # $ rake install # # == Getting started # # First of all, for a better understanding of this gem, make sure you study the 'dm-is-list/spec/integration/list_spec.rb' file. # # ---- # # Require +dm-is-list+ in your app. # # require 'dm-core' # must be required first # require 'dm-is-list' # # # Lets say we have a User class, and we want to give users the possibility of # having their own todo-lists. # # # class User # include DataMapper::Resource # # property :id, Serial # property :name, String # # has n, :todos # end # # class Todo # include DataMapper::Resource # # property :id, Serial # property :title, String # property :done, DateTime # # belongs_to :user # # # here we define that this should be a list, scoped on :user_id # is :list, :scope => [:user_id] # end # # Once we have our Users and Lists, we might want to work with... # # == Movements of list items # # Any list item can be moved around within the same list easily through the move method. # # # === move( vector ) # # There are number of convenient vectors that help you move items around within the list. # # item = Todo.get(1) # other = Todo.get(2) # # item.move(:highest) # moves to top of list. # item.move(:lowest) # moves to bottom of list. # item.move(:top) # moves to top of list. # item.move(:bottom) # moves to bottom of list. # item.move(:up) # moves one up (:higher and :up is the same) within the scope. # item.move(:down) # moves one up (:lower and :down is the same) within the scope. # item.move(:to => position) # moves item to a specific position. # item.move(:above => other) # moves item above the other item.* # item.move(:below => other) # moves item above the other item.* # # # * won't move if the other item is in another scope. (should this be enabled?) # # The list will act as intelligently as possible and keep positions in a logical running order. # # # === move( Integer ) # # NOTE! VERY IMPORTANT! # # If you set the position manually, and then save, the list will NOT reorganize itself. # # item.position = 3 # setting position manually # item.save # the item will now have position 3, but the list may have two items with the same position. # # # alternatively # item.update(:position => 3) # sets the position manually, but does not reorganize the list positions. # # # You should therefore always use the item.move(N) syntax instead. # # item.move(3) # does the same as above, but in one call AND *reorganizes* the list. # #
# # Hold On! # # dm-is-list used to work with item.position = 1 type syntax. Why this change? # # The main reason behind this change was that the previous version of dm-is-list created a LOT of # extra SQL queries in order to support the manual updating of position, and as a result had a quite a few bugs/issues, # which have been fixed in this version. # # The other reason is that I couldn't work out how to keep the functionality without adding the extra queries. But perhaps you can ? # #
# # See "Batch Changing Positions" below for information on how to change the positions on a whole list. # # == Movements between scopes # # When you move items between scopes, the list will try to work with your intentions. # # # Move the item from list to new list and add the item to the bottom of that list. # # item.user_id # => 1 # item.move_to_list(10) # => the scope id ie User.get(10).id # # # results in... # item.user_id # => 10 # item.position # => < bottom of the list > # # # Move the item from list to new list and add at the position given. # # item.user_id # => 1 # item.move_to_list(10, 2) # => the scope id ie User.get(10).id, position => 2 # # # results in... # item.user_id # => 10 # item.position # => 2 # # # == Batch Changing Positions # # A common scenario when working with lists is the sorting of a whole list via something like JQuery's sortable() functionality. #
# (Think re-arranging the order of Todo's according to priority or something similar) # # # === Optimum scenario # # The most SQL query efficient way of changing the positions is: # # # sort_order = [5,4,3,2,1] # list from AJAX request.. # # items = Todo.all(:user => @u1) # loads all 5 items in the list # # items.each{ |item| item.update(:position => sort_order.index(item.id) + 1) } # remember the +1 since array's are indexed from 0 # # # The above code will result in something like these queries. # # # SELECT "id", "title", "position", "user_id" FROM "todos" WHERE "user_id" = 1 ORDER BY "position" # # UPDATE "todos" SET "position" = 5 WHERE "id" = 1 # # UPDATE "todos" SET "position" = 4 WHERE "id" = 2 # # UPDATE "todos" SET "position" = 2 WHERE "id" = 4 # # UPDATE "todos" SET "position" = 1 WHERE "id" = 5 # # Remember! Your sort order list has to be the same length as the found items in the list, or your loop will fail. # # # === Wasteful scenario # # You can also use this version, but it will create upto 5 times as many SQL queries. :( # # # sort_order = ['5','4','3','2','1'] # list from AJAX request.. # # items = Todo.all(:user => @u1) # loads all 5 items in the list # # items.each{ |item| item.move(sort_order.index(item.id).to_i + 1) } # remember the +1 since array's are indexed from 0 # # The above code will result in something like these queries: # # # SELECT "id", "title", "position", "user_id" FROM "todos" WHERE "user_id" = 1 ORDER BY "position" # # # SELECT "id", "title", "position", "user_id" FROM "todos" WHERE "user_id" = 1 ORDER BY "position" DESC LIMIT 1 # # SELECT "id" FROM "todos" WHERE "user_id" = 1 AND "id" IN (1, 2, 3, 4, 5) AND "position" BETWEEN 1 AND 5 ORDER BY "position" # # UPDATE "todos" SET "position" = "position" + -1 WHERE "user_id" = 1 AND "position" BETWEEN 1 AND 5 # # SELECT "id", "position" FROM "todos" WHERE "id" IN (1, 2, 3, 4, 5) ORDER BY "id" # # UPDATE "todos" SET "position" = 5 WHERE "id" = 1 # # # SELECT "id", "title", "position", "user_id" FROM "todos" WHERE "user_id" = 1 ORDER BY "position" DESC LIMIT 1 # # SELECT "id" FROM "todos" WHERE "user_id" = 1 AND "id" IN (1, 2, 3, 4, 5) AND "position" BETWEEN 1 AND 4 ORDER BY "position" # # UPDATE "todos" SET "position" = "position" + -1 WHERE "user_id" = 1 AND "position" BETWEEN 1 AND 4 # # SELECT "id", "position" FROM "todos" WHERE "id" IN (2, 3, 4, 5) ORDER BY "id" # # UPDATE "todos" SET "position" = 4 WHERE "id" = 2 # # # ... # # As you can see it will also do the job, but will be more expensive. # # # == RTFM # # As I said above, for a better understanding of this gem/plugin, make sure you study the 'dm-is-list/spec/integration/list_spec.rb' tests. # # # == Errors / Bugs # # If something is not behaving intuitively, it is a bug, and should be reported. # Report it here: http://datamapper.lighthouseapp.com/ # module List ## # method for making your model a list. # TODO:: this explanation is confusing. Need to translate into literal code # # it will define a :position property if it does not exist, so be sure to have a # position-column in your database (will be added automatically on auto_migrate) # if the column has a different name, simply make a :position-property and set a # custom :field # # @example [Usage] # is :list # put this in your model to make it act as a list. # is :list, :scope => :user_id # you can also define scopes # is :list, :scope => [ :user_id, :context_id ] # also works with multiple params # # @param options a hash of options # # @option :scope an array of attributes that should be used to scope lists # # @api public def is_list(options={}) options = { :scope => [], :first => 1 }.merge(options) # coerce the scope into an Array options[:scope] = Array(options[:scope]) extend DataMapper::Is::List::ClassMethods include DataMapper::Is::List::InstanceMethods unless properties.any? { |p| p.name == :position && p.primitive == Integer } property :position, Integer end @list_options = options before :create do # if a position has been set before save, then insert it at the position and # move the other items in the list accordingly, else if no position has been set # then set position to bottom of list __send__(:move_without_saving, position || :lowest) # on create, set moved to false so we can move the list item after creating it # self.moved = false end before :update do # a (new) position has been set => move item to this position (only if position has been set manually) # the scope has changed => detach from old list, and possibly move into position # the scope and position has changed => detach from old, move to pos in new # if the scope has changed, we need to detach our item from the old list if list_scope != original_list_scope newpos = position detach(original_list_scope) # removing from old list __send__(:move_without_saving, newpos || :lowest) # moving to pos or bottom of new list end # NOTE:: uncommenting the following creates a large number of extra un-wanted SQL queries # hence the commenting out of it. # if attribute_dirty?(:position) && !moved # __send__(:move_without_saving, position) # end # # on update, clean moved to prepare for the next change # self.moved = false end before :destroy do detach end # we need to make sure that STI-models will inherit the list_scope. after_class_method :inherited do |retval, target| target.instance_variable_set(:@list_options, @list_options.dup) end end # is_list module ClassMethods attr_reader :list_options ## # use this function to repair / build your lists. # # @example [Usage] # MyModel.repair_list # repairs the list, given that lists are not scoped # MyModel.repair_list(:user_id => 1) # fixes the list for user 1, given that the scope is [:user_id] # # @param scope [Hash] # # @api public def repair_list(scope = {}) return false unless scope.keys.all?{ |s| list_options[:scope].include?(s) || s == :order } retval = true all({ :order => [ :position.asc ] | default_order }.merge(scope)).each_with_index do |item, index| retval &= item.update(:position => index.succ) end retval end end module InstanceMethods # @api semipublic attr_accessor :moved ## # returns the scope of the current list item # # @return ...? # # @example [Usage] # Todo.get(2).list_scope => { :user_id => 1 } # # # @api semipublic def list_scope model.list_options[:scope].map{ |p| [ p, attribute_get(p) ] }.to_hash end ## # returns the _original_ scope of the current list item # # @return ...? # # @example [Usage] # item = Todo.get(2) # with user_id 1 # item.user_id = 2 # item.original_list_scope => { :user_id => 1 } # # @api semipublic def original_list_scope model.list_options[:scope].map{ |p| [ p, (property = properties[p]) && original_attributes.key?(property) ? original_attributes[property] : attribute_get(p) ] }.to_hash end ## # returns the query conditions # # @return ...? # # @example [Usage] # Todo.get(2).list_query => { :user_id => 1, :order => [:position] } # # @api semipublic def list_query list_scope.merge(:order => [ :position ]) end ## # returns the list the current item belongs to # # @param scope Optional (Default is #list_query) # # @return the list items within the given scope # # @example [Usage] # Todo.get(2).list => [ list of Todo items within the same scope as item] # Todo.get(2).list(:user_id => 2 ) => [ list of Todo items with user_id => 2] # # @api public def list(scope = list_query) model.all(scope) end ## # repair the list this item belongs to # # @api public def repair_list model.repair_list(list_scope) end ## # reorder the list this item belongs to # # @param order ...? # # @return True/False based upon result # # @example [Usage] # Todo.get(2).reorder_list([:title.asc]) # # @api public def reorder_list(order) model.repair_list(list_scope.merge(:order => order)) end ## # detaches a list item from the list, essentially setting the position as nil # # @param scope Optional (Default is #list_scope) # # @return the list items within the given scope # # @example [Usage] # # @api public def detach(scope = list_scope) list(scope).all(:position.gt => position).adjust!({ :position => -1 },true) self.position = nil end ## # moves an item from one list to another # # @param scope must be the id value of the scope # @param pos Optional sets the entry position for the item in the new list # # @example [Usage] # Todo.get(2).move_to_list(2) # Todo.get(2).move_to_list(2, 10) # # @return True/False based upon result # # @api public def move_to_list(scope, pos = nil) detach # remove from current list attribute_set(model.list_options[:scope][0], scope.to_i) # set new scope save # save progress. Needed to get the positions correct. reload # get a fresh new start move(pos) unless pos.nil? end ## # finds the previous _higher_ item in the list (lower in number position) # # @return the previous list item # # @example [Usage] # Todo.get(2).left_sibling => Todo.get(1) # Todo.get(2).higher_item => Todo.get(1) # Todo.get(2).previous_item => Todo.get(1) # # @api public def left_sibling list.reverse.first(:position.lt => position) end alias_method :higher_item, :left_sibling alias_method :previous_item, :left_sibling ## # finds the next _lower_ item in the list (higher in number position) # # @return the next list item # # @example [Usage] # Todo.get(2).right_sibling => Todo.get(3) # Todo.get(2).lower_item => Todo.get(3) # Todo.get(2).next_item => Todo.get(3) # # @api public def right_sibling list.first(:position.gt => position) end alias_method :lower_item, :right_sibling alias_method :next_item, :right_sibling ## # move item to a position in the list. position should _only_ be changed through this # # @example [Usage] # * node.move :higher # moves node higher unless it is at the top of list # * node.move :lower # moves node lower unless it is at the bottom of list # * node.move :below => other_node # moves this node below the other resource in the list # * node.move :above => Node.get(2) # moves this node above the other resource in the list # * node.move :to => 2 # moves this node to the position given in the list # * node.move(2) # moves this node to the position given in the list # # @param vector An integer, a symbol, or a key-value pair that describes the requested movement # # @option :higher move item higher # @option :lower move item lower # @option :up move item higher # @option :down move item lower # @option :highest move item to the top of the list # @option :lowest move item to the bottom of the list # @option :top move item to the top of the list # @option :bottom move item to the bottom of the list # @option :above move item above other item. must be in same scope # @option :below move item below other item. must be in same scope # @option :to Integer/String}> move item to a specific position in the list # @option move item to a specific position in the list # # @return returns false if it cannot move to the position, otherwise true # @see move_without_saving # # @api public def move(vector) move_without_saving(vector) && save end private ## # does all the actual movement in #move, but does not save afterwards. this is used internally in # before :create / :update. Should not be used by organic beings. # # @see move # # @api private def move_without_saving(vector) if vector.kind_of?(Hash) action, object = vector.keys[0], vector.values[0] else action = vector end # set the start position to 1 or, if offset in the list_options is :list, :first => X minpos = model.list_options[:first] # the previous position (if changed) else current position prepos = original_attributes[properties[:position]] || position # set the last position in the list or previous position if the last item maxpos = (last = list.last) ? (last == self ? prepos : last.position + 1) : minpos newpos = case action when :highest then minpos when :top then minpos when :lowest then maxpos when :bottom then maxpos when :higher,:up then [ position - 1, minpos ].max when :lower,:down then [ position + 1, maxpos ].min when :above # the object given, can either be: # -- the same as self # -- already below self # -- higher up than self (lower number in list) ( (self == object) or (object.position > self.position) ) ? self.position : object.position when :below # the object given, can either be: # -- the same as self # -- already above self # -- lower than self (higher number in list) ( self == object or (object.position < self.position) ) ? self.position : object.position + 1 when :to # can only move within top and bottom positions of list # -- .move(:to => 2 ) Hash with FixNum # -- .move(:to => '2' ) Hash with String # NOTE:: sensitive functionality # maxpos is incremented above, so decrement by 1 to get true maxpos # minpos is fixed, so just take the object position value given # else add 1 to object position value obj = object.to_i if (obj > maxpos) [ minpos, [ obj, maxpos - 1 ].min ].max else [ minpos, [ obj, maxpos ].min ].max end else raise ArgumentError, "unrecognized vector: [#{action}]. Please check your spelling and/or the docs" if action.is_a?(Symbol) # -- .move(2) as FixNum only # -- .move('2') as String only if action.to_i < minpos [ minpos, maxpos - 1 ].min else [ action.to_i, maxpos - 1 ].min end end # don't move if already at the position return false if [ :lower, :down, :higher, :up, :top, :bottom, :highest, :lowest, :above, :below ].include?(action) && newpos == prepos return false if !newpos || ([ :above, :below ].include?(action) && list_scope != object.list_scope) return true if newpos == position && position == prepos || (newpos == maxpos && position == maxpos - 1) if !position list.all(:position.gte => newpos).adjust!({ :position => 1 }, true) unless action =~ /:(lowest|bottom)/ elsif newpos > prepos newpos -= 1 if [:lowest,:bottom,:above,:below].include?(action) list.all(:position => prepos..newpos).adjust!({ :position => -1 }, true) elsif newpos < prepos list.all(:position => newpos..prepos).adjust!({ :position => 1 }, true) end self.position = newpos self.moved = true true end # move_without_saving end # InstanceMethods end # List end # Is Model.append_extensions(Is::List) end # DataMapper