Tutorial: build a drag-n-drop kanban board on Rails with SortableJS
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.
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
, adescription
. I will use thescaffold
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
content
and 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_allmy_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 changed
an 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 💪).