RESTful Drag & Drop with Rails and Multiple Models
For a client project we recently had to implement a sortable list using jQuery UI’s sortable plugin. …but with a nasty twist: The collection being sorted consisted of two unrelated models.
The technique not only does this in a highly-RESTful manner, but also has the added benefit of pushing all of your jQuery sorting needs into a single controller, no matter what models your working with. Here’s how we did it.
The problem
In our situation, we have a model that has many Foos and Bars.
class Container < ActiveRecord::Base
has_many :foos
has_many :bars
#...
We want to render these Foos and Bars to the user as though they were all part of the same collection, and we want to allow the user to sort the Foos and Bars together.

Normal jQuery UI Sortable Usage
The well-documented way of using the jQuery UI sortable module is to have it serialize the DOM and send that in via a POST to your backend. This works handily with a single model, but the serialization loses the position information when dealing with multiple models.
The traditional DOM (generated by div_for) looks like this:
<div class="sortable">
<div id="foo_1" class="foo">…</div>
<div id="bar_1" class="bar">…</div>
<div id="foo_6" class="foo">…</div>
<div id="bar_3" class="bar">…</div>
<div id="foo_23" class="foo">…</div>
</div>
The URL-encoded data comes in like such:
foos[]=1&bars[]=1&foos[]=6&bars[]=3&foos[]=23
…where the order of the params represents the new sorted order. Once Rails unencodes it, we end up with two arrays of IDs:
params = {
foos: [1, 6, 23],
bars: [1, 3]
}
As you can see, we now no longer know how to mingle the Foos and Bars. What we want is something more like this:
params = {
0 => [“foo”, 1],
1 => [“bar”, 1],
2 => [“foo”, 6],
3 => [“bar”, 3],
4 => [“foo”, 23]
}
Let’s see how we can make that happen.
Sorting the Output
First of all, let’s render the DOM such that the two collections are
intermingled, and sorted according to the #position attributes. We’ll
add a method on the model to do this:
class Container < ActiveRecord::Base
has_many :foos
has_many :bars
def foos_and_bars
[*foos,*bars].sort_by(&:position)
end
#…
end
Normally, sorting in ruby would be a huge performance no-no. In this case we know we’re dealing with at most a dozen items on any given page load, which gives us the ability to do things the easy way.
We then loop over this pseudo-collection in the view:
Foos and Bars
<% container.foos_and_bars.each do |model| %>
<%= render model %>
<% end %>
The DOM
Next, we need to add some identifying data to the rendered DOM, which we can send back in the POST. Like we saw earlier, using div_for and serialize doesn’t do what we need. We’ll use HTML5 data- attributes to be a bit more explicit.
Foos and Bars
<% container.foos_and_bars.each do |model| %>
<%= div_for(model, class:"element", data: {id: model.id.to_i, model: model.class.to_s}) do %>
<%= render model %>
<% end %>
<% end %>
The nested data hash in there produces well-formed HTML5 data
attributes:
...
The Javascript
We add an update: entry to the normal sortable() call, which does
two things:
- Constructs a
new_orderarray that contains the[id, class]pairs. - Posts that array as
sortingto a single RESTful Sortings controller
For example:
$(function() {
$( “.sortable” ).sortable({
axis: ‘x’,
opacity: 0.4,
update: function() {
var new_order = [];
$(this).find(“.element”).each(function(index, element) {
new_order.push([ \$(element).data().id, $(element).data().model ]);
});
$.post(“/sortings”, { sorting: new_order })
}
});
$( “.sortable” ).disableSelection();
});
The Controller
Our controller is a normal RESTful resource, which fronts a Sorting
class.
class SortingsController < ApplicationController
load_and_authorize_resource
def create
sorting.save
render nothing: true, status: 200
end
end
We're using CanCan in this application, and we dry up our controllers with load_and_authorize_resource. If you weren't using CanCan, you'd do something like sorting = Sorting.new(params[:sorting]) inside the create method.
Now we hook up the routes:
MyApp::Application.routes.draw do
resource :sortings
# …
And the rest of the magic happens in the model.
The Model
The Sorting model is a PORC (plain old ruby class), which quacks like
an ActiveModel.
class Sorting
attr_accessor :new_order
def save
ActiveRecord::Base.transaction do
new_order.each do |position, id_model_pair|
update_position(position, id_model_pair)
end
end
end
def update_position(position, id_model_pair)
instance(id_model_pair).update_attributes(position: position)
end
def instance(id_model_pair)
model_id, model_class = *id_model_pair
model_class.constantize.find(model_id)
end
# Quack like a model…
def initialize(new_order = {})
self.new_order = new_order
end
extend ActiveModel::Naming
include ActiveModel::Conversion
def persisted?
false
end
end
The point of this model is to coordinate the sorting of the multiple models. There are arguments about what kind of GoF pattern this is. I personally don’t care if you call it a Decorator, Composite or Presenter. It’s clean, easily understood, and gets the job done.
Bonus
Note that the SortingController and the Sorting model both have no
knowledge of the Foo and Bar models. They simply work in concert to
change the sorting order of any models that come their way. This means
we can use this single route (/sortings) for all of our sorting
needs! That’s a pretty big win on its own.
Security Concern?
The astute reader will note that there’s a potential security risk here.
By using #constantize (a wrapper around #eval) on a user-supplied
string, we’re giving the user a chance to execute arbitrary code. This
is important to be aware of, but is mitigated by three facts:
- Only authenticated and trusted users are able to access this method for our particular use case.
- The only method being called on the generated class is
#position=, meaning at most, a user can change the position of an item. - If the user attempts to inject a class that doesn’t respond to
#position=, they’ll get a 500 due to aMethodMissingexception. Raising an exception is absolutely the right thing to do here.
Discuss this post on Hacker News
*Keep it real, we'll erase shit we don't like.* comments powered by Disqus