6 Dual List Boxes 4 comments
Matthew Ryan asks:
One thing I have been wondering what the best way to approach in Ruby on Rails is Dual List Boxes. You may of seen them on some sites where you can click the arrows in between the two lists and move values from one to the other.
This is a bit complicated to be taken in from a screencast, so I thought a standard post was more appropriate, and whilst this is not directly related to Rails, I think it would be a useful feature within many web applications so lets dive right in.
Analysis
The situation is a need to allow users to select values from a predefined list - so basically this would plainly be a list of checkboxes, so that's where we will start. In Rails, this functionality is what a tagging system would provide, and there are numerous gems and plugins around for that, but they may not be suitable for any system, so we'll build our own using simply has_and_belongs_to_many relationships.
Personally, I am an advocate of so-called unobtrusive JavaScript, which allows you to create websites and applications without requiring JavaScript. Because of this I always start in Rails, creating functionality without any JavaScript, then building on top.
Rails-side
In my application we already have products, so for the example we'll generate categories of which products can be included within, you can add more fields if you want, but just a name and ID will do for now:
script/generate scaffold category name:string
You can now boot up Rails and populate the table with a few categories. Next we need our link table. For has_and_belongs_to_many relationships the table name should be the two current table names in alphabetical order separated by an underscore, so products and categories become "categories_products", so lets create our migration:
script/generate migration create_categories_products
And fill it with our relations:
create_table :categories_products do |t|
t.belongs_to :category
t.belongs_to :product
end
Run the migration, and then we can go into our models and setup the relation with has_and_belongs_to_many followed by the name of the linked model:
In app/models/product.rb:
has_and_belongs_to_many :categories
In app/models/category.rb:
has_and_belongs_to_many :products
So there we have our relationship setup, now for the interesting bit. In our form for creating and editing the products (I suggest it's in a partial of it's own), we need to display the checkboxes.
<div class="dual_control">
<% for category in Category.find(:all, :order => 'categories.name ASC') do %>
<div>
<%= check_box_tag "product[category_ids][]", category.id, @product.categories.include?(category), :id => "product_categories_#{category.id}" %>
<label for="product_categories_<%= category.id %>"><%= category.name %></label>
</div>
<% end %>
</div>
What you are seeing above is a container for each dual control, they are identified by the class, so you can have many per page. We use Rails to loop through each category and output a checkbox and label for each one, we will use the label for the select list. It is important each checkbox has it's own unique ID because JavaScript will use it to make the changes in the background for Rails to process.
Rails will process the checkboxes as an array because the names end with empty square brackets, and it will even setup the linkage automatically, so no additional code is necessary in your model or controller (except for one line below).
The only thing you need to add to complete the functionality is in the controller, because if you deselect all the checkboxes nothing will be sent so add the line below, directly below the start of your update method. This basically forces Rails to empty the associations if no value is passed.
params[:product][:category_ids] ||= []
Now we have our basic system working you can test it in your browser, no JavaScript is needed.
JavaScript niceties
So this is all nicely working, and you could stop there, but from a usability point of view - millions of checkboxes isn't ideal, so dual list boxes is the obvious choice... until you realise how difficult they are to code. Well, to be honest, they aren't difficult, just lengthy.
So to save you all a multiple-day-spanning coding spree, I have written a Prototype based class to simply the solution into one copy-paste action, all you need to do is copy and paste this into your public/javascripts/application.js file:
var DualControl = Class.create({
initialize: function(element, size) {
// setup variables
if(size == null) size = 5;
// local data store
this.element = $(element)
this.selected_items = new Array();
this.deselected_items = new Array();
// create the new form
this.deselected_box = document.createElement('select')
this.deselected_box.size = size
this.selected_box = document.createElement('select')
this.selected_box.size = size
this.button_container = document.createElement('div')
this.button_container.addClassName('button_container')
this.remove_button = document.createElement('button')
this.remove_button.innerHTML = '< Remove'
this.add_button = document.createElement('button')
this.add_button.innerHTML = 'Add >'
// loop through the items
var items = this.element.childElements()
for(var i = 0; i < items.length; i++) {
var input = items[i].down('input')
var label = items[i].down('label')
// if it's checked
if(input && input.checked) {
// add to selected
this.selected_items[this.selected_items.length] = [input.id, label.innerHTML]
this.selected_box.options.add(this.create_option(input.id, label.innerHTML))
// if it's not checked
} else {
// add to deselected
this.deselected_items[this.deselected_items.length] = [input.id, label.innerHTML]
this.deselected_box.options.add(this.create_option(input.id, label.innerHTML))
}
// hide the element - but don't use hide() because
// that sets the display to none, which means it won't
// be passed to the server.
items[i].addClassName('hidden')
}
// add the new form
this.element.appendChild(this.deselected_box)
// bind as event listener passes 'this' as this object,
// otherwise the event would thing 'this' is the button.
this.remove_button.observe('click', this.remove.bindAsEventListener(this))
this.button_container.appendChild(this.remove_button)
this.button_container.appendChild(this.add_button)
this.element.appendChild(this.button_container)
this.add_button.observe('click', this.add.bindAsEventListener(this))
this.element.appendChild(this.selected_box)
},
add: function(e) {
Event.stop(e)
// if an item is selected in the left hand box
if(this.deselected_box.selectedIndex >= 0) {
// load the item from the array
var item = this.deselected_items[this.deselected_box.selectedIndex]
// if the item is found
if(item) {
// check the box
$(item[0]).writeAttribute('checked', true)
// remove the item from the deselected array
this.deselected_items.splice(this.deselected_box.selectedIndex, 1)
// remove it from the deselected box (the left one)
this.deselected_box.remove(this.deselected_box.selectedIndex)
// add the item to the selected array
this.selected_items[this.selected_items.length] = item
// add it to the selected box (the right one)
this.selected_box.options.add(this.create_option(item[0], item[1]))
}
}
},
remove: function(e) {
Event.stop(e)
// see documentation above
// but in reverse
if(this.selected_box.selectedIndex >= 0) {
var item = this.selected_items[this.selected_box.selectedIndex]
if(item) {
$(item[0]).writeAttribute('checked', false)
this.selected_items.splice(this.selected_box.selectedIndex, 1)
this.selected_box.remove(this.selected_box.selectedIndex)
this.deselected_items[this.deselected_items.length] = item
this.deselected_box.options.add(this.create_option(item[0], item[1]))
}
}
},
create_option: function(value, text) {
// just used for quickly creating options
// for a select box
var optn = document.createElement("option");
optn.text = text;
optn.value = value;
return optn;
}
})
I appreciate I haven't really taught you about the JavaScript here, but to analyse 100 lines of code which is all pretty understandable just from looking at it is rather pointless - and I am sure you would appreciate another screencast instead. I've included as many comments as possible for those who wish to understand it.
For our final slice of JavaScript we need to iterate through all the elements with a class of dual_control when the window loads to set them up:
document.observe('dom:loaded', function() { initiate_dual_control() })
var initiate_dual_control = function() {
els = $$('.dual_control');
for(var i = 0; i < els.length; i++) new DualControl(els[i], 10);
}
Styling
You can customise the style to however it suits your app, but you need to make sure the hidden class is defined to hide without using display:none, as below:
.hidden {
width:0;
height:0;
overflow:hidden;
display:block;
opacity:0;
}
Lastly here is my example styles:
.dual_control {
height:100px;
}
.dual_control select {
height:100px;
float:left;
}
.dual_control .button_container {
width:80px;
height:50px;
margin:25px 5px;
float:left;
}
.dual_control .button_container button {
width:80px;
}
Any problems or queries feel free to let me know in the comments. You can download a zip of my files here.