Sortable Bootstrap Tables (in Rails)

Update September 2014: This guide has been updated to be compatible with Rails 4 and now comes with a demo + Github repo

Overview

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

sortable bootstrap

LIVE DEMO

Github repo

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.

Install the gem, include it into your model and add an Integer column to your model’s table called :row_order

Gemfile
1
gem 'ranked-model'
models/thing.rb
1
2
3
4
class Thing < ActiveRecord::Base
  include RankedModel
  ranks :row_order
end
db/migrate/20140908010519_add_row_order_to_things.rb
1
2
3
4
5
class AddRowOrderToThings < ActiveRecord::Migration
  def change
    add_column :things, :row_order, :integer
  end
end

Twitter Bootstrap

I use the official Sass port of Twitter Bootstrap — https://github.com/twbs/bootstrap-sass

Update Row Order Controller Action

The first code to write is the controller action that performs the reordering, updating row_order_position on an instance of Thing is all that is needed. The action will need the position number to move it to, i.e. 2nd place, and the id of the instance. We will specify these in the markup later and send them to this action via an AJAX POST.

controllers/admin/things_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ThingsController < ApplicationController

  ### snip

  def update_row_order
    @thing = Thing.find(thing_params[:thing_id])
    @thing.row_order_position = thing_params[:row_order_position]
    @thing.save

    render nothing: true # this is a POST action, updates sent via AJAX, no view rendered
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_thing
      @thing = Thing.find(params[:id])
    end

    # Never trust parameters from the scary internet, only allow the white list through.
    def thing_params
      params.require(:thing).permit(:thing_id, :title, :description, :row_order_position)
    end
end

Add a route for the sort action

config/routes.rb
1
2
3
4
5
6
7
8
resources :things do
  post :update_row_order, on: :collection
end

# Which gives..

# update_row_order_things POST   /things/update_row_order(.:format) things#update_row_order

Now we can add some Javascript.. Use the JQuery UI Gem

We need the sortable + highlight modules.

assets/javascripts/admin/application.js
1
2
3
4
5
6
//= require jquery
//= require jquery_ujs
//= require jquery-ui/sortable
//= require jquery-ui/effect-highlight
//= require turbolinks
//= require_tree .

Here we lift the relevant values from the rendered markup and perform the POST to /things/update_row_order

assets/javascripts/admin/update_things_row_order.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
29
jQuery ->
  if $('#sortable').length > 0
    table_width = $('#sortable').width()
    cells = $('.table').find('tr')[0].cells.length
    desired_width = table_width / cells + 'px'
    $('.table td').css('width', desired_width)

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

      sort: (e, ui) ->
        ui.item.addClass('active-item-shadow')
      stop: (e, ui) ->
        ui.item.removeClass('active-item-shadow')
        # highlight the row on drop to indicate an update
        ui.item.children('td').effect('highlight', {}, 1000)
      update: (e, ui) ->
        item_id = ui.item.data('item-id')
        console.log(item_id)
        position = ui.item.index() # this will not work with paginated items, as the index is zero on every page
        $.ajax(
          type: 'POST'
          url: '/things/update_row_order'
          dataType: 'json'
          data: { thing: {thing_id: item_id, row_order_position: position } }
        )
    )

Table Markup

Now add your bootstrap table markup. The table rows have a class of .item as specified in the JQuery UI sortable options. The relevant thing.id is added to each row so that can be passed to the update_row_order controller action.

views/admin/things/index.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<table class="table table-bordered table-striped" id="sortable">
  <thead>
    <tr>
      <th>
        Title
      </th>
      <th>
        Description
      </th>
    </tr>
  </thead>
  <tbody>
    <% @things.each do |thing| %>
      <tr data-item-id=<%= "#{thing.id}" %> class="item">
        <td>
          <%= thing.title %>
        </td>
        <td>
          <%= link_to 'show', thing_path(thing), :class => 'btn btn-default pull-right' %>
        </td>
      </tr>
    <% end %>
  </tbody>
</table>

Comments