Sorting Lists with Ranked Model

Intro

I tweeted earlier this week that one unexpected benefit of writing very detailed technical how-to articles on this blog is that I can refer back to my own posts and they’re EXACTLY what I need :D

Unexpected payoff

I kicked off a new personal project last weekend (more about that later), and having so much fun! The modern Rails stack is insanely good these days and a joy to work with. But wait… I need to allow the user to sort a list… I’ve implemented this probably 20 different ways in the last couple of decades. Wish I had made some notes… Aha! Blog post incoming. My future self thanks me.

Common Problem

Be it recipes, ingredients, steps, tasks, days, meal plans todo lists. It seems everything I build needs sorting. Most recently I added - then removed - sorting from 1500cals.com but it was relatively fresh in my mind. Or at least git commit history ;)

My new project has lists of Tasks that need to be sortable. A few other use cases of other models need sorting, so I know I need something generic. We’ll build this super simple version in this article.

Simple Example Task Sorting

Getting Started

Back in the day, the go-to solution was acts_as_list that I’m sure was around in the Rails 1.2 era when I was first using Rails professionally. It’s still available and is a fine solution. But I remember finding RankedModel at some point and liked the simplicity and seemingly smarter, less database write-intensive strategy.

Ok, so RankedModel it is for tha backend, and along with SortableJS, some StimulusJS sprinkles and a bit of Rails and we’ll be up and running!

If you have an app already and are adding this, skip to the section titled ## 2. Ranked Model. If you’re starting from scratch, lets build a quick app to get going.

1. Example App

Here we go. Open your terminal and enter these 3 commands:

rails new tasklist -c tailwind
rails g scaffold task description:string
rails db:migrate

It never ceases to amaze me that with those 3 commands, you have a bootstrapped modern webapp, ready to start collecting data to a database. Not a toy, but the foundation of a world class app. But it’s true. Start the server with the command below:

bin/dev

and go to http://localhost:3000/tasks and voila! You should be seeing your app, with a “New” button. From there you can create tasks and we’re off!

2. Ranked Model

Ranked Model works by adding a row_order column to the target model’s database table, and simply updates this when a row is moved. It uses large numbers and wide ranges to mitigate the need to update all items in a list when just one is moved. Smart. Lets get started:

Note, in this article we’re working on a model named Task and hence ordering lists of Tasks. You can do this for any model you want to be able to sort, eg Recipe, Book, Day or whatever. Just change the reference obviously to your model name!

Add the gem to our bundle:

bundle add ranked-model

Now we need to add a row_order column to the database for Tasks. Rails generators are our friends, so lets use one and then also migrate the database:

rails g migration AddRowOrderToTasks row_order:integer
rails db:migrate

Then to make our model sortable we need to add the following:

class Task < ActiveRecord::Base
  include RankedModel
  ranks :row_order
end

Lastly lets update our controller at app/controllers/tasks_controller.rb by replacing all the text with this:

class TasksController < ApplicationController
  before_action :set_task, only: %i[ show edit update destroy position ]

  def index
    @tasks = Task.rank(:row_order).all
  end

  def show
  end

  def new
    @task = Task.new
  end

  def edit
  end

  def position
    @task.update(row_order_position: params[:row_order_position])
  end

  def create
    @task = Task.new(task_params)

    respond_to do |format|
      if @task.save
        format.html { redirect_to task_url(@task), notice: "Task was successfully created." }
        format.json { render :show, status: :created, location: @task }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @task.errors, status: :unprocessable_entity }
      end
    end
  end

  def update
    respond_to do |format|
      if @task.update(task_params)
        format.html { redirect_to task_url(@task), notice: "Task was successfully updated." }
        format.json { render :show, status: :ok, location: @task }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @task.errors, status: :unprocessable_entity }
      end
    end
  end

  def destroy
    @task.destroy!

    respond_to do |format|
      format.html { redirect_to tasks_url, notice: "Task was successfully destroyed." }
      format.json { head :no_content }
    end
  end

  private
    def set_task
      @task = Task.find(params[:id])
    end

    def task_params
      params.require(:task).permit(:description, :row_order_position)
    end
end

If you’re updating your own app, skip the above and find the controller for the model you’re sorting. All we need to do is add the ranked model sorting magic to your index action. In our example case thats changing @tasks = Task.all to ` @tasks = Task.rank(:row_order).all`

def index
  @tasks = Task.rank(:row_order).all
end

adding a position action below. ** see end of article for a caveat **

def position
  @task.update(row_order_position: params[:row_order_position])
end

and then making sure the model is set for that new action by adding position to our list of before_actions that set_task

before_action :set_task, only: %i[ show edit update destroy position ]

and lastly permitting :row_order_position in the strong parameter section

def task_params
  params.require(:task).permit(:description, :row_order_position)
end

The final thing for the backend is to add a route to map the URL path to the position controller action. Open up config/routes.rb and add the put :position below inside your :tasks resource. This means paths like /tasks/11/position will get mapped to the “sort” action in our TasksController.

resources :tasks do
  put :position, on: :member
end

Thats it for the backend!! Now on to the UI

3. SortableJS

SortableJS handles pretty much all the front end in this case. There are a myriad of features and options and definitely worth taking a look, but in our case, just the basics. We want a drag handle that the user can drag in order to sort the list. Thats it!

First, lets add the SortableJS library. We’re using importmaps here so we pin it like this:

bin/importmap pin sortablejs

Then lets generate our Stimulus controller at app/javascript/controllers/sortable_controller.js like this:

rails g stimulus sortable

4. Wiring things together

OK! Now the fun part, we need to add a little markup our Rails views that connects the stimulus controller and then passes the URL and ID’s the controller needs to make the updates in the model.

You’ll typically be rendering out a list by iterating over a collection if items - in our case Tasks - so the view is in app/views/tasks/index.html.erb. Something like this, that we will add to next:

<div>
  <%= render @tasks %>
</div>

We need to firstly connect the Stimulus controller by name with data-controller="sortable" which first the connect() function in our Stimulus controller. We also pass in a value to our stimulus controller with data-sortable-path-value="tasks" which we’ll use later to construct the path to update. Lets add this to the index view:

<div id="tasks" class="min-w-full" data-controller="sortable" data-sortable-path-value="tasks">
  <%= render @tasks %>
</div>

Then, if we’re following the conventions - I know you are! - then the partial that represents each “Task” will be in the file app/views/tasks/_task.html.erb.

We’re going to add a couple of data attributes like before, this time to pass in the ID of the record with data-id. Notice also data-sortable-handle lets us specify exactly the draggable part in the interface. This would usually be a cool icon, but for simplicity I’ve just used the text [=].

<div data-id="<%= task.id %>">
  <div class="my-5"><span class="cursor-move" data-sortable-handle>[=]<span> <%= task.description %></div>
</div>

Now, open up the Stimulus controller at app/javascript/controllers/sortable_controller.js and paste in the code below:

import { Controller } from "@hotwired/stimulus";
import Sortable from "sortablejs";

export default class extends Controller {
  static values = { path: String };

  connect() {
    this.sortable = Sortable.create(this.element, {
      ghostClass: "bg-gray-100",
      handle: "[data-sortable-handle]",
      animation: 400,
      onEnd: this.end.bind(this),
    });
  }

  end(event) {
    const movedItemId = event.item.dataset.id;
    const newIndex = event.newIndex;

    fetch(`/${this.pathValue}/${movedItemId}/sort`, {
      method: "PUT",
      headers: {
        "X-CSRF-Token": document.head.querySelector("[name='csrf-token']")
          .content,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        id: movedItemId,
        row_order_position: newIndex,
      }),
    });
  }
}

What this is doing is when the page loads and the connect() function is called we bind a callback to the data-sortable-handle that means when a user drags that item in the list, when they let go and drop it, the end(event) function is called. There are tons of things you can tweak with the SortableJS library. Here I added ghostClass for example, which gives a nice gray dropzone as you drag your tasks around.

When you let go of your mouse button, and effectively drop the task in its new position, end(event) is called in the Stimulus controller where it grabs the ID of the Task you moved from the data-id attribute in the markup using the js event with event.item.dataset.id. We then grab event.newIndex which is the new position we moved the task to! The we simply PUT this to the server, at which point our Rails controller updates the position in the model with @task.update(row_order_position: params[:row_order_position])!

Thats it. Wow, this got long again, but as we all know, I’m writing this for myself to save time next time when I inevitably need to do this yet again!

** One caveat. I really don’t like to add random actions like position in this case, and would normally create a new restful SortableController or similar, but in this case when I’m updating a couple of different models, I bend the rules. **

Wrapping up

Don’t hesitate to drop me a note, or send corrections, via twitter or any other channels listed here