Tutorial: build a drag-n-drop kanban board on Rails with SortableJS

Yann Klein
Le Wagon Tokyo
Published in
9 min readSep 10, 2020

Drag-n-drop is a very popular and natural user interaction on today’s websites. We will learn in this tutorial how to build a kanban board (or any other type of board: scheduler, calendar, restaurant seating chart, soccer team position map,…) that saves its changes automatically in a Rails app.

Photo by QuickOrder on Unsplash

Setup

I will start by building a simple Rails website. I will use once again the great Le Wagon Rails template as a base:

rails new \
--database postgresql \
-m https://raw.githubusercontent.com/lewagon/rails-templates/master/minimal.rb \
dragndrop_kanban_app

This base template will build my website backbone. I specify that I want to use PostgreSQL as a database, and I name my app “dragndrop_kanban_app”.

Add SortableJS

I will use SortableJS (on Yarn, on NPM) to manage the drag’n drop effect. It is a JS plugin that enables you to move elements of one or several <ul>. To install it on my app I will use Yarn (you can also use NPM of course):

yarn add sortablejs

Create a Kanban Model

I will want to store my Kanban with all its cards in my Database. For this I will create:

  • a Kanban Model in my Rails app with a name, a description. I will use the scaffold Rails command to have some basic views too.
  • a KanbanColumn Model with a name (in case I want to add/remove columns in further developments), relative to a Kanban
  • a Card Model relative to a KanbanColumn with a contentand aposition within the column.
rails g scaffold Kanban name description cards:jsonb
rails g model KanbanColumn name kanban:references
rails g model card content position:integer kanban_column:references
rails db:migrate

Create a Kanban seed

When the model is done, I create a Kanban, 3 columns and their cards in my seeds:

# dragndrop_kanban_app/db/seed.rbCard.destroy_all
KanbanColumn.destroy_all
Kanban.destroy_all
my_kanban = Kanban.create(
name: "New Lamborgucci project",
description: "Project to build the most esthetically car ever made.",
);
backlog = KanbanColumn.create(
name: "Backlog",
kanban: my_kanban
)
Card.create(content: "Build engine", position: 0, kanban_column: backlog)
Card.create(content: "Purchase the tires", position: 1, kanban_column: backlog)
Card.create(content: "Code the cockpit software", position: 2, kanban_column: backlog)
todo = KanbanColumn.create(
name: "To Do",
kanban: my_kanban
)
Card.create(content: "Design the car", position: 0, kanban_column: todo)
completed = KanbanColumn.create(
name: "Completed",
kanban: my_kanban
)
Card.create(content: "Build the engineer team", position: 0, kanban_column: completed)
Card.create(content: "Find fundings", position: 1, kanban_column: completed)

And I run the seed:

rails db:seed

Display the Kanban

After this long setup, it is time to display my Kanban. Let’s run the Rails server:

rails s

I can already have some raw information about the Kanban on the show page: http://localhost:3000/kanbans/1

Start with HTML/CSS

I will put my kanban’s cards information in a <ul> and style it in a nice way.

I will add this code to my show:

# dragndrop_kanban_app/app/views/kanbans/show.html.erb...<div class="kanban" data-id="<%= @kanban.id %>">
<% @kanban.kanban_columns.each do |column| %>
<ul class="kanban-col" data-col-id=<%= column.id %> >
<li class="kanban-col-name"><%= column.name %></li>
<% column.cards.sort_by{ |card| card.position}.each do |item| %>
<li class="kanban-col-item" data-item-id=<%= item.id %> >
<%= item.content %>
</li>
<% end %>
</ul>
<% end %>
</div>
...

And I will create a kanban component in my styling:

# dragndrop_kanban_app/app/assets/stylesheets/components/_kanban.scss.kanban {
display: flex;
margin: 24px;
}
.kanban-col {
list-style: none;
width: 160px;
padding: 0;
margin-left: 8px;
}
.kanban-col-name {
background-color: lightblue;
text-align: center;
margin-bottom: 16px;
padding: 16px;
}
.kanban-col-item {
background-color: white;
margin-bottom: 4px;
padding: 16px;
cursor: grab;
}
.kanban-col-item:active {
cursor: grabbing;
}

And link it to my component’s index:

# dragndrop_kanban_app/app/assets/stylesheets/components/_index.scss...@import "kanban";

The result is a quite simple but effective Kanban design. That shows a nice hand cursor when I try to grab a card:

Add animation with JS

Time to build the JS part. I will create a file to initiate our SortableJS animation and apply it to my kanban.

I first create a function taking and array of <ul> and applying SortableJS to them:

# dragndrop_kanban_app/app/javascript/plugins/initSortable.jsimport Sortable from 'sortablejs';const initKanbanSortable = (ulElements) => {
ulElements.forEach((ul) => {
new Sortable(ul, {
group: 'kanban', // set both lists to same group
animation: 300
});
});
};
export { initKanbanSortable };

And I link it to the application.js :

# dragndrop_kanban_app/app/javascript/packs/application.js...document.addEventListener('turbolinks:load', () => {
const kanbanUls = document.querySelectorAll(".kanban .kanban-col");
if (kanbanUls) {
initKanbanSortable(kanbanUls);
}
});

The result is pretty neat! Now, I can move my cards within and between the columns of my Kanban 🎉 drag, drop, drag, drop… oh I love it ❤️

But, I have a problem, when I refresh my webpage, the kanban cards come back to their initial position. The changes I made by drag’n dropping them has not been saved.

I am missing the backend part of my draggable kanban.

Store the changes in the DB

I will build my kanban changes DB storing feature in two steps. First, I will use a typical form_with to test that my controller newsort action works fine, then I will use a badass Rails AJAX request to store the kanban anytime I drag’n drop a card.

Setup the strategy: define a format to bear the cards order

My big goal here is to get the new card order of the kanban, and tell my DB how to reorder the cards to match it. I will need to store this new order in a variable. For this reason, I define kanbanIds which will be a hash containing all my column with their ids and their card with their ids:

kanbanIds = { 
"columns": [
{ "id": 1, "itemIds": [3, 2] },
{ "id": 2, "itemIds": [4, 5] },
{ "id": 3, "itemIds": [6, 1] }
]
}

The above kanbanIds corresponds to a kanban with 3 columns:

  • a KanbanColumn of id 1 (this_kanban_column.id #=> 1), having two cards with ids 3 and 2. The card 3 has a position of 0 (this_card.position #=> 0). The card 2 has a position of 1.
  • a KanbanColumn of id 2 (that_kanban_column.id #=> 2), having two cards with ids 4 and 5. The card 4 has a position of 0 (that_card.position #=> 0). The card 5 has a position of 1.
  • a KanbanColumn of id 3, having two cards with ids 6 and 1. The card 6 has a position of 0. The card 1 has a position of 1.

This kanbanIds will be our format to transmit the cards order from the view to our DB and back!

First step: save the kanban update with a simple_form_for

For this step, I will first build a form_with that is sending a new kanbanIds my backend:

# dragndrop_kanban_app/app/views/kanbans/show.html.erb...<%= form_with url: kanban_sort_path, method: :patch do |f|%>
<%= f.text_field 'kanban[kanbanIds]', class: "kanban-form-input" %>
<%= f.submit "Saved changes" %>
<% end %>
<div class="kanban">
<% @kanban.cards["columns"].each do |column| %>
<ul class="kanban-col">
<li class="kanban-col-name"><%= column["name"] %></li>
<% column["items"].each do |item| %>
<li class="kanban-col-item"><%= item %></li>
<% end %>
</ul>
<% end %>
</div>
...

Then, I will modify my initSortable.js so that it modifies the value of my form input each time I finish a drag’n drop:

# dragndrop_kanban_app/app/javascript/plugins/initSortable.jsimport Sortable from 'sortablejs';const initKanbanSortable = (ulElements) => {
const saveKanbanBinded = saveKanban.bind(null, ulElements);
ulElements.forEach((ul) => {
new Sortable(ul, {
group: 'kanban', // set both lists to same group
animation: 300,
onEnd: saveKanbanBinded
});
});
};
const kanbanForm = document.querySelector(".kanban-form-input");const saveKanban = (ulElements) => {
// Let's build an Object kanbanIds containing all the kanban Ids
// E.g. :
// {
// "columns": [
// { "id": 1, "itemIds": [3, 2] },
// { "id": 2, "itemIds": [4, 5] },
// { "id": 3, "itemIds": [6, 1] }
// ]
// }
const kanbanIds = {"columns": []};
ulElements.forEach(ul => {
const itemIds = [];
ul.querySelectorAll(".kanban-col-item")
.forEach(item => itemIds.push(Number.parseInt(item.dataset.itemId,10)));
kanbanIds.columns.push(
{
'id': Number.parseInt(ul.dataset.colId,10),
'itemIds': itemIds
}
);
});
kanbanForm.value = JSON.stringify(kanbanIds);
}
export { initKanbanSortable };

Let me explain a bit that code. 🤓

First, I create a saveKanban function. This function. takes all my kanban’s <ul> and creates an object new_cards which contains the new kanban card structure. It then store this structure as a JSON in kanbanForm.value ( = the value of my form_with input).

Second, I create saveKanbanBinded which binds saveKanban to ulElements (the <ul> of the kanban). In other words saveKanbanBinded is the equivalent of saveKanban in which ulElements is embedded so that you don’t need to pass it as an argument.

Finally, I let Sortable call the saveKanbanBinded when a drag’n drop is completed thanks to Sortable’s onEnd attribute.

After, the HTML and the JS, I also need to modify a part of my kanban_controller. I will create a new sort action. I will also modify my strong params so that Rails accept my kanbanIds as a parameter:

# dragndrop_kanban_app/app/controllers/kanban_controller.rb...def sort
# Get the new col sort
sorted_cols = JSON.parse(kanban_params["kanbanIds"])["columns"]
sorted_cols.each do |col|
# Look at each of its cards
col["itemIds"].each do |card_id|
# Find the card if in the DB and
# update its column and position within the column
Card.find(card_id).update(
kanban_column: KanbanColumn.find(col["id"]),
position: col["itemIds"].find_index(card_id)
)
end
end
end
...private ...# Only allow a list of trusted parameters through.
def kanban_params
params.require(:kanban).permit(:name, :description, :kanbanIds)
end

I will also create the route to access to my sort action:

# dragndrop_kanban_app/config/routes.rbRails.application.routes.draw do
resources :kanbans
patch '/kanbans/:id/sort', to: 'kanbans#sort', as: "kanban_sort"
root to: 'pages#home'
# For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end

Ok done! The result is great! It works like a charm 😍

First, my kanban and form look like that:

Then, when I drag’n drop all the Backlog cards to To Do the form value is updated right away. Look at the "id":1,"items":[] , it’s empty now!!

And when I Save changedan refresh my page, the order is preserved 🎉 🚀🤘🎊

To make it more beautiful, I could hide my form_with input with a as: :hidden attribute, but instead of that, I will use Rails AJAX to save the kanban as soon as I drag’n drop a card!

Second step: rock my drag’n drop with AJAX

This step is actually quick!

I will first get rid of my form_with:

# dragndrop_kanban_app/app/views/kanbans/show.html.erb...<% if false %>
<%= form_with url: kanban_sort_path, method: :patch do |f|%>
<%= f.text_field 'kanban[kanbanIds]', class: "kanban-form-input" %>
<%= f.submit "Saved changes" %>
<% end %>
<% end %>
...

Then, I will add my Rails AJAX in initSortable.js (don’t forget the import Rails!):

# dragndrop_kanban_app/app/javascript/plugins/initSortable.jsimport Sortable from 'sortablejs';
import Rails from "@rails/ujs";
...const kanbanForm = document.querySelector(".kanban-form-input");
const saveKanban = (ulElements) => {
// Let's build an Object kanbanIds containing all the kanban Ids
// E.g. :
// {
// "columns": [
// { "id": 1, "itemIds": [3, 2] },
// { "id": 2, "itemIds": [4, 5] },
// { "id": 3, "itemIds": [6, 1] }
// ]
// }
const kanbanIds = {"columns": []};
ulElements.forEach(ul => {
const itemIds = [];
ul.querySelectorAll(".kanban-col-item")
.forEach(item => itemIds.push(Number.parseInt(item.dataset.itemId,10)));
kanbanIds.columns.push(
{
'id': Number.parseInt(ul.dataset.colId,10),
'itemIds': itemIds
}
);
});
// kanbanForm.value = JSON.stringify(kanbanIds);
const kanbanId = document.querySelector(".kanban").dataset.id;
const formData = new FormData();
formData.append('kanban[kanbanIds]', JSON.stringify(kanbanIds));
Rails.ajax({
url: `/kanbans/${kanbanId}/sort`,
type: "patch",
data: formData
})
}
export { initKanbanSortable };

Boom! done! With this code, I just need to drag and drop my cards, the new kanban structure is automatically saved. If I refresh the page, the order is preserved! Another victory 🎊😄🥂🎌

I hope you enjoyed this tutorial. Here is the complete source code on Github and the live website to help you.

Find more info about me here and Le Wagon Tokyo coding bootcamp (we have a lot of other tutorials and workshops besides our world #1 code learning program 💪).

--

--