ruby-on-railsrubyrails-activerecordacts-as-list

Ordering a has_many list in view


I have a model called Person that the user selects five personality Traits for. However, the order they pick them for matters (they are choosing most descriptive to least descriptive).

I know how to create a join table with a poison an do ordering that way. I'm using acts_as_list as well.

But I can't seem to find any help on, is how to create a way for the user of my app to set the order of the traits. That is I want to have say five select boxes on in the HTML and have them pick each one, and use something like jQuery UI Sortable to allow them to move them around if they like.

Here is a basic idea of my models (simplified for the purpose of just getting the concept).

class Person < ActiveRecord::Base
  has_many :personalizations
  has_many :traits, :through => :personalizations, :order => 'personalizations.position'
end

class Personalization < ActiveRecord::Base
  belongs_to :person
  belongs_to :trait
end

class Trait  < ActiveRecord::Base
  has_many :persons
  has_many :persons, :through => :personalizations
end

I just have no idea how to get positioning working in my view/controller, so that when submitting the form it knows which trait goes where in the list.


Solution

  • After a lot of research I'll post my results up to help someone else encase they need to have list of records attached to a model via many-to-many through relationship with being able to sort the choices in the view.

    Ryan Bates has a great screencast on doing sorting with existing records: http://railscasts.com/episodes/147-sortable-lists-revised

    However in my case I needed to do sorting before my Person model existed.

    I can easily add an association field using builder or simple_form_for makes this even easier. The result will be params contains the attribute trait_ids (since my Person has_many Traits) for each association field:

    #view code (very basic example)
    <%= simple_form_for @character do |f| %>
      <%= (1..5).each do |i| %>
        <%= f.association :traits %>
      <% end %>
    <% end %>
    
    #yaml debug output
    trait_ids:
      - ''
      - '1'
      - ''
      - '2'
      - ''
      - '3'
      - ''
      - '4'
      - ''
      - '5'
    

    So then the question is will the order of the elements in the DOM be respected whenever the form is submitted. Specially if I implement jQuery UI draggable? I found this Will data order in post form be the same to it in web form? and I agree with the answer. As I suspected, too risky to assume the order will always be preserved. Could lead to a bug down the line even if it works in all browsers now.

    Therefore after much looking I've concluded jQuery is a good solution. Along with a virtual attribute in rails to handle the custom output. After a lot of testing I gave up on using acts_as_list for what I am trying to do.

    To explain this posted solution a bit. Essentially I cache changes to a virtual property. Then if that cache is set (changes were made) I verify they have selected five traits. For my purposes I am preserving the invalid/null choices so that if validation fails when they go back to the view the order will remain the same (e.g. if they skipped the middle select boxes).

    Then an after_save call adds these changes to the database. Any error in after_save is still wrapped in a transaction so if any part were to error out no changes will be made. It was easiest therefore to just delete all the endowments and save the new ones (there might be a better choice here, not sure).

    class Person < ActiveRecord::Base
      attr_accessible :name, :ordered_traits
    
      has_many :endowments
      has_many :traits, :through => :endowments, :order => "endowments.position"
    
      validate :verify_list_of_traits
    
      after_save :save_endowments
    
      def verify_list_of_traits
        return true if @trait_cache.nil?
    
        check_list = @trait_cache.compact
    
        if check_list.nil? or check_list.size != 5
          errors.add(:ordered_traits, 'must select five traits')
        elsif check_list.uniq{|trait| trait.id}.size != 5
          errors.add(:ordered_traits, 'traits must be unique')
        end
      end
    
      def ordered_traits
        list = @trait_cache unless @trait_cache.nil?
        list ||= self.traits
        #preserve the nil (invalid) values with '-1' placeholders
        list.map {|trait| trait.nil?? '-1' : trait.id }.join(",")
      end
    
      def ordered_traits=(val)
        @trait_cache = ids.split(',').map { |id| Trait.find_by_id(id) }    
      end
    
      def save_endowments
        return if @trait_cache.nil?
        self.endowments.each { |t| t.destroy }
        i = 1
        for new_trait in @trait_cache
          self.endowments.create!(:trait => new_trait, :position => i)
          i += 1
        end
      end
    

    Then with simple form I add a hidden field

      <%= f.hidden :ordered_traits %>    
    

    I use jQuery to move the error and hint spans to the correct location inside the div of five select boxes I build. Then I had a submit event handler on the form and convert the selection from the five text boxes in the order they are in the DOM to an array of comma separated numbers and set the value on the hidden field.

    For completeness here is the other classes:

    class Trait < ActiveRecord::Base
      attr_accessible :title
      has_many :endowments
      has_many :people, :through => :endowments
    end
    
    class Endowment < ActiveRecord::Base
      attr_accessible :person, :trait, :position
      belongs_to :person
      belongs_to :trait
    end