Rails: Multiple Select Helper plugin

Ver esta página en español.

Multiple Select Helper screenshot

Important: Version 20070407 and greater of the plugin are not “compatible” with the code for prior versions. If you still want to use your unmodified code you should stick to version 20060918. More information below.

Selecting multiple elements in a list is sometimes tricky. You may click inadvertably on one item of the list and lost all your previous selection. You are forced to use Ctrl (or Command) clicks to select more than one element.

Multiple Select Helper allows you to create easy to use list for multiple selections from array, hashes, collections or trees. The list is build using checkboxes so you can click easily and you will not lost the elements you clicked before. As drawback you lose the use of the keyboard in the “list”.

This multiple selections are very useful in many to many relationships (has_and_belongs_to_many or has_many :through with no obligatory fields) where a “add and remove” solution will be cumbersome.

The last version of the plugin works with Rails 2.2 (RC1 and hopefully with the final version). The last version is not compatible with Rails versions before 2.2. Version 20080608 is compatible and tested against Rails 2.1.0, 2.0.2 and 1.2.6. The only difference between the two versions is the Rails 2.2 compatibility. Previous versions of the plugin worked with Rails 1.1, but I don’t know if that hold true for the newer versions.

Installation

You can download the plugin from:

You can use script/plugin install url to install it in your Rails project.

About the versions

I began developing the plugin for Rails 1.1, which do not have #collection_singular_ids method in has_many and has_and_belongs_to_many associations, so you can not use collection_singular_ids as you use other ActiveRecord methods in the helpers from ActionView::Helpers::FormHelpers, because that helpers need both the attribute reader and the attribute writer. Therefore the main version of the plugin works more like the ActionView::Helpers::FormTagHelpers helpers.

Parallel to the main version was another version (which I called “object-method pararmeters” branch) which implement collection_singular_ids and helpers like que ones in ActionView::Helpers::FormHelper, but because Rails developers did not like this method then (ticket #2917 and ticket #5780) I did not want to make that version the default version of the plugin.

Seems like somebody convince Rails developers that simmetry is a good thing and Rails 1.2 have both collection_singular_ids and collection_singular_ids= implemented for has_many and has_and_belongs_to_many so the “alternative” version of the plugin can be merged with the main version.

The merge was not free of new problems, because the alternative and the the main versions use the same method names for the helpers, so I have to break backwards compatibility of one of them. The logical solution will have been break compatibility with the alternative version, but I decided to break compatibility with the main version so the name of the methods follow the names of the Rails official helpers. Sorry for the inconvenience.

Using Multiple Select Helper Plugin

This is a human approach to the plugin, for the formal documentation you can go to Multiple Select Helper plugin RDoc documentation.

There are 3 pairs of functions that you can use depending of the source of your data:

  • multiple_select (and multiple_select_tag and checkboxes_for_multiple_select): With arrays, hashes or your own classes (implementing first and last in them).
  • collection_multiple_select (and collection_multiple_select_tag and checkboxes_from_collection_for_multiple_select): With arrays of classes using text_method and value_method to access the data in the instances.
  • tree_multiple_select (and tree_multiple_select_tag and checkboxes_from_tree_for_multiple_select): With tree-like structures (like ActiveRecord’s actsastree) using text_method and value_method to access the data in the instances.

All three posibilites supports the following options in the options hash:

  • outer_class: Specifies the class of the div that wraps all the checkboxes.
  • selected_items: Specifies an array of items that should be selected when the list renders (only in the three main methods, the other three use the selected_items parameter instead). :selected_items array should be an array of values to be matched with the ones provided by value_method.
  • inner_class: Specifies the class of the div that wraps each checkbox.
  • position: Determines the position of the label besides the checkbox. The value should be :right or :left. The default is :right.
  • alternate: Determines if the class of each of the checkboxes should alternate. The default is not alternating classes.
  • alternate_class: Specifies the alternative class that will be used if alternate option is used. The alternative class will be added to the inner_class options if it is also specified. The default alternative class is “alt”.
  • initial_alternate: Determines if the first element of the list should be the alternative one or not. The default is that the first element is not the alternative one.
  • disabled: If disabled is true all the checkboxes will be disabled. If disabled is an array only the checkboxes in the array will be disabled. If disabled is false no checkboxes will be disabled. The default is false.

Besides the options above described the two tree methods supports also the following options:

  • depth: Maximum depth the tree will be trasversed. A depth of 0 will only show the nodes of the first level. The default is traverse all the tree.
  • child_method: The method that will be send to the node to obtain its children. This method must only return an array of the direct children of the node. The default is “children” (valid for acts_as_tree. For acts_as_nested_set use “direct_children” instead).
  • level_class: Specifies the preffix of the class that will be used in each of the divs that wrap each checkbox. The level_class will be suffixed with a incresing number according the level of the actual checkbox. This class will be added to the inner_class and the alternative_class if they are also specified.
  • initial_level: Specifies the first level that will be used as preffix of level_class. The default is 0.

Since 20060917 version you could establish default option for some of the most used parameters (outer_class, inner_class, level_class, alternate, alternate_class and position). To set the new default values of this variables you can write in your environment.rb:

FightTheMelons::Helpers::FormMultipleSelectHelperConfiguration.outer_class = 'myclass'

And every call to every method of the module will use an default outer_class of myclass (you can always pass a new value in the options hash and it will be used instead of the default value).

There is an additional variable that you can not set using the options hash of the methods but you can set using a module variable. This variable is call is list_tags and is an array of two strings that will be used to wrap the list and the individual list items, respectively. So if you want your checkboxes wrapped by divs like in pre-20060917 versions you can set the variable like this:

FightTheMelons::Helpers::FormMultipleSelectHelperConfiguration.list_tags = ['div', 'div']

Note that this line does NOT produce the exact same results as pre-20060917 versions of the plugin in the tree methods (collection and normal methods produce the same results, as far as I know).

When you want to store your list of checked options in a “habtm” relationship you could use something like:

# In the model
class Person < ActiveRecord::Base
  has_and_belongs_to_many :fruits
end

# In the view
<%=
  collection_multiple_select(
    'person', 'fruit_ids', Fruit.find(:all), :id, :name
  )
%>

# In the controller
def new # or edit
  # Instance variable @person has to be created
end

def create # or update
  # ...
  @person.fruit_ids = params[:person][:fruit_ids]
  # ...
end

And you will have all the fruits you have selected linked to the person you are editing.

The results in the screenshot above use the following CSS:

.multiple_select {
  border: 1px solid #aaa;
  height: 100px;
  width: 200px;
  padding: 2px;
  margin: 1em;
  overflow: auto;
}

.multiple_select, .multiple_select ul {
  list-style-type: none;
}

.multiple_select_checkbox {
  background: #fff;
}

.alt {
  background: #eef;
}

Using “multiple_select” as outer_class and “multiple_select_checkbox” as inner_class.

How to capture the events on the checkboxes

The plugin doesn’t offer an easy way to attach events to any of the elements the plugin creates, but you can do it with a bit of Javascript.

<div id="my-id">
<%= multiple_select_tag 'my-name', [1, 2, 3, 4, 5, 6] %>
</div>
<%= javascript_tag %q{
function myOnClickFunction(event) {
  var element = Event.element(event);
  alert(element.value + ' clicked!');
}

var list = $('my-id');
var checkboxes = list.getElementsByTagName('input');
for (var i = 0; i < checkboxes.length; i++) {
  Event.observe(checkboxes[i], 'click', myOnClickFunction);
}
} %>

The first thing is wrap the multiple select helper in a div with and id, so we can use Prototype dollar function. We can then use the getElementsByTagName of this element looking for the input tags (that will be the checkboxes). We add the myOnClickFunction to all the click events of the checkboxes (using Prototype Event.observe). In the myOnClickFunction we can get the element which fired the event looking at the properties of the structure that we get as parameter.

For all this to work you will have to include prototype.js in the header.

How to validate that at least one collection element was selected.

Rails treats model fields and model associations different. While fields are not saved directly to the database (only when a method like save is invoked), in some situations associations are directly saved to the database without even call some callback methods from the parent. One of that situations seems to be using collection_singular_ids=. I will try to sumarize the steps needed to validate your collections when using Multiple Select Helper plugin.

First you need the code to validate your model, which will reside in your model class:

# app/models/person.rb
class Person < ActiveRecord::Base
  has_and_belongs_to_many :fruits

protected
  def validate
    if self.fruits.count == 0
      errors.add(:fruits_ids, "must be selected.")
      return false;
    end

    return true
  end
end

The next step is fire the validation code with the new data but without the new data being saved to the database if something did not pass the validation. For that to happen we need to use transactions.

# app/controllers/people_controller.rb
# ...
def update
  @person = Person.find(params[:id])
  Person.transaction do
    @person.fruit_ids = params[:person][:fruit_ids]
    @person.save! # Is important to use the ! version of save
  end
  redirect_to :action => 'list'
rescue ActiveRecord::RecordNotSaved, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound
  flash[:notice] = "Could not update person"
  render :action => 'edit'
end
# ...

That way an invalid record will not save the new association data, because is commited in a transaction that will be rollbacked when the validation fails.

How to use with has_many :through associations

Note: This section only applies to versions older than Rails 2.1. If you are using something newer, you can stop reading.

Rails has_many :through associations do not generate collection_singular_ids nor collection_singular_ids=, so you can not use Multiple Select Helper plugin in that kind of associations.

The reason for not having those methods is that has_many :through associations usually have extra data about the association, and this data will be lost (or created null) when those methods are used.

If your has_many :through association do not have that extra data or if the extra data can be null you can recreate both methods in your model easily:

# app/models/person.rb
class Person < ActiveRecord::Base
  has_many :authorships
  has_many :books, :through => :authorships

  def book_ids
    self.books.map(&:id)
  end

  def book_ids=(new_ids)
    new_ids = (new_ids || []).reject(&:blank?)
    old_ids = self.book_ids

    self.transaction do
      Authorships.destroy_all({ :book_id => old_ids - new_ids, :person_id => self.id })
      (new_ids - old_ids).each do |book_id|
        self.authorships.create!(:book_id => book_id)
      end
    end
  end
end

(I have not tested this code deeply, but I think it should work fine. Thanks to Brian Warren for the fixing it).

Authors

Daniel Rodríguez Troitiño (drodrigueztroitino@yahoo.es): main programmer.
Michael Schuerig (michael@schuering.de): ideas for disabled option, hidden field option and the <ul> and <li> tags.

License

This work is released under the MIT License.

Tu servidor sin límites: 20GB de espacio, 1TB de transferencia, 1 dominio gratuito. Por 1.5€ al mes utilizando el código "RUIDOBLANCO" en DreamHost. Más información.