Sortable Bootstrap Tables (in Rails)

Overview

How to add drag-and-drop reordering on a twitter-bootstrap table in Rails. Uses ranked-model and jQuery-UI.

sortable bootstrap

Sortable List Gems

If you need to make a Rails resource type re-orderable, you have a couple of options in gem-land. acts_as_list is probably the most popular of these. My problem with this gem is this method..

list.rb link
1
2
3
4
5
6
# This has the effect of moving all the lower items down one.
def increment_positions_on_lower_items(position)
  acts_as_list_class.unscoped.update_all(
    "#{position_column} = (#{position_column} + 1)", "#{scope_condition} AND #{position_column} >= #{position}"
 )
end

To change one item’s position, all items in the list that follow it must have their position updated also. Once you have more than a few items in your database, changing their order ends up being very expensive and slow. There has to be a better way!

ranked-model to the rescue - originally put together by Harvest.

And here’s ranked-model’s solution to the problem..

ranker.rb link
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def update_index_from_position
  case position

    # snip ...

    when Integer
      neighbors = neighbors_at_position(position)
      min = (neighbors[:lower] ? neighbors[:lower].rank : RankedModel::MIN_RANK_VALUE)
      max = (neighbors[:upper] ? neighbors[:upper].rank : RankedModel::MAX_RANK_VALUE)
      rank_at( ( ( max - min ).to_f / 2 ).ceil + min )

    # snip ...

  end
end

So, ranked-model only performs an update operation on the item in question. It achieves this by creating a rank number that is midway between the two neighbors, these numbers are spread out across a very wide range; -8388607 to 8388607 (the extents of a signed MEDIUMINT in MySQL.

What happens if, after a few thousand re-sorts on a list there is actually an overlap with rank-numbers, or they end up out of range? Well, in that rare scenario the whole table will be updated..

ranker.rb link
1
2
3
4
5
6
7
8
9
10
11
def assure_unique_position
  if ( new_record? || rank_changed? )
    unless rank
      rank_at( RankedModel::MAX_RANK_VALUE )
    end

    if (rank > RankedModel::MAX_RANK_VALUE) || current_at_rank(rank)
      rearrange_ranks
    end
  end
end

This is much more elegant. Realistically speaking, unless you’re dealing with thousands of items your web-app won’t suffer the problems that this gem solves. However, ranked-model is so easy to set up that I can’t see any good reason not to use it.

Twitter Bootstrap

There are a few ways to add bootstrap into your project, you could find a non-less version, i.e. css, and copy in the files or you could use one of the many available gems. Most of them are engines, some of them use the original less and rely on the less-rails gem to translate it. I often use compass in Rails apps, so it would seem that the compass-twitter-bootstrap gem is a good option.

Table Markup

Now you can add your bootstrap markup for a table to the index view-template, (sorry haml-haters!).

views/admin/things/index.html.haml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
%table.table.table-bordered.table-striped
  %thead
    %tr
      %th= Title
      %th= Description
      %th  

  %tbody
    - @things.each do |thing|
      %tr
        %td= thing.title
        %td= thing.description.truncate(20)
        %td
          = link_to 'show', admin_thing_path(thing), :class => 'btn'
          = link_to 'edit', edit_admin_thing_path(thing), :class => 'btn btn-primary'
          = link_to 'destroy', admin_thing_path(thing), method: :delete, confirm: "Are you sure?", :class => 'btn btn-danger'

Sort Action

Next you’ll need to add the #sort action to your controller..

controllers/admin/things_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Admin::ThingsController < Admin::ApplicationController

  def sort
    @thing = Thing.find(params[:id])

    # .attributes is a useful shorthand for mass-assigning
    # values via a hash
    @thing.attributes = params[:thing]
    @thing.save

    # this action will be called via ajax
    render nothing: true
  end

end

Don’t forget to add a route for it!

config/routes.rb
1
2
3
4
5
6
7
8
9
10
namespace :admin do
  resources :things do
    # The id param is passed via js, so we can use a generic route.
    post :sort, on: :collection
  end
end

# Which gives..

# sort_admin_things  POST  /admin/things/sort(.:format)  admin/things#sort

Now we can add some Javascript..

assets/javascripts/admin/application.js
1
2
3
4
5
//= require jquery
//= require jquery_ujs
//= require jquery.ui.sortable
//= require jquery.effects.highlight
//= require ./sort
assets/javascripts/admin/sort.js.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
jQuery ->

  # this is a small hack; when a tr is dragged with jQuery UI sortable
  # the cells lose their width
  cells = $('.table').find('tr')[0].cells.length
  desired_width = 940 / cells + 'px'
  $('.table td').css('width', desired_width)

  $('#sortable').sortable(
    axis: 'y'
    items: '.item'

    # highlight the row on drop to indicate an update
    stop: (e, ui) ->
      ui.item.children('td').effect('highlight', {}, 1000)
    update: (e, ui) ->
      item_id = ui.item.data('item_id')
      position = ui.item.index()
      $.ajax(
        type: 'POST'
        url: $(this).data('update_url')
        dataType: 'json'

        # the :thing hash gets passed to @thing.attributes
        # row_order is the default column name expected in ranked-model
        data: { id: item_id, thing: { row_order_position: position } }
      )
  )

Next we need to add into the markup the data expected by the Javascript.

views/admin/things/index.html.haml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# the update url is passed via an html5 data attribute
%table.table.table-bordered.table-striped#sortable{:data => {update_url:
sort_admin_things_path}}
  %thead
    %tr
      %th= Title
      %th= Description
      %th &nbsp;

  %tbody
    - @things.each do |thing|

      # make the <tr>'s draggable by adding the expected '.item' class
      # pass the item id into params[:id]
      %tr{data: {item_id: "#{thing.id}"}, class: 'item'}
        %td= thing.title
        %td= thing.description.truncate(20)
        %td
          = link_to 'show', admin_thing_path(thing), :class => 'btn'
          = link_to 'edit', edit_admin_thing_path(thing), :class => 'btn btn-primary'
          = link_to 'destroy', admin_thing_path(thing), method: :delete, confirm: "Are you sure?", :class => 'btn btn-danger'

Finally, to indicate that the items are re-orderable I chose to change the cursor to row-resize pointer.

assets/stylesheets/admin/application.css.scss
1
2
3
4
5
#sortable {
  tr.item {
    cursor: row-resize;
  }
}

Comments