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
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.
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_action
s 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