This article was originally published on Rails Designer
This one has been in my "articles backlog" for a long time. But for whatever reason it kept getting pushed back. I've built drag & drop features multiple times in the past, but recently I helped a new client with some custom UI work and a kanban-like feature was on the list. So I assessed again what resources for such a feature for Rails and Hotwire are out there on the internet, and concluded that there is a place for another article as my approach is really clean, I think.
This is the feature I want to build in this article:
The solution is using three key components to make life easier:
- SortableJS—my go-to library for drag and drop features, just helps with a few niceties (opposed to writing it yourself);
- Rails Request.js—any request I need to make from a Stimulus controller I use this small package, specifically useful for around CSRF;
- Positioning—if you need to store a position for a resource, use this gem (I mentioned it before).
This is then "packaged" into a Stimulus controller of less than 30 lines of code! 🤯
Let's go over how I approach when I need such a feature. As always the full source can be found on GitHub.
Data Model
Start by creating the basic models for the board:
class Board < ApplicationRecord
has_many :columns, -> { order(position: :asc) }, class_name: "Board::Column", dependent: :destroy
end
class Board::Column < ApplicationRecord
belongs_to :board
has_many :cards, -> { order(position: :asc) }, class_name: "Board::Card", foreign_key: "board_column_id", dependent: :destroy
positioned on: :board
def to_partial_path = "boards/column"
end
positioned on: :board
is coming from the positioning
gem.
The to_partial_path
method is an interesting tidbit that allows for a cleaner file structure. Instead of Rails looking for board/columns/_column.html.erb
, it will look for boards/_column.html.erb
. You will see in a moment how this is used cleanly.
The Card
model uses a polymorphic association:
class Board::Card < ApplicationRecord
belongs_to :column, class_name: "Board::Column", foreign_key: "board_column_id"
belongs_to :resource, polymorphic: true
positioned on: :column
def to_partial_path = "boards/card"
end
This polymorphic relationship is a neat idea that's often overlooked by even intermediate developers when building such a feature into an existing app (where existing resources need to be added to a “board”). For this example, I'm using a Message
model, but you could easily attach tasks, tickets, or any other model to your board. For more complicated setups, Rails' delegated types could work too.
Adding Basic UI
I am using Tailwind CSS to quickly get started, but feel free to use realjust CSS if you want. ✌️
Create the views for the board, columns, and cards:
<%# app/views/boards/_board.html.erb %>
<h1 class="mx-4 mt-2 text-lg font-bold text-gray-800">
<%= board.name %>
</h1>
<ul class="flex w-full gap-x-8 overflow-x-auto mt-2 px-4">
<%= render board.columns %>
</ul>
<%# app/views/boards/_column.html.erb %>
<li class="w-80 h-[calc(100dvh-4rem)] shrink-0 bg-gray-100 shadow-inner rounded-lg overflow-y-auto">
<h2 class="sticky top-0 px-4 py-2 font-medium text-gray-800 bg-gray-100">
<%= column.name %>
</h2>
<ul class="flex flex-col gap-y-2 px-4 pb-4">
<%= render column.cards %>
</ul>
</li>
<%# app/views/boards/_card.html.erb %>
<li class="px-3 py-2 bg-white rounded-md shadow-xs">
<%= tag.p resource.title, class: "text-sm font-normal text-gray-900" %>
</li>
Notice how I am using render board.columns
? This Rails feature, by default uses the conventional partial look up app/views/board/columns/_column.html.erb
, but since I updated the to_partial_path
method in the Column- and Card model, it now looks for app/views/board/_{column,card}.html.erb
. Fair bit cleaner!
Re-order columns and cards
Now for the fun part. To make the cards reorder-able (sortable) within their columns, use SortableJS and Rails Request.js (bin/importmap pin sortablejs @rails/request.js
).
First: a Stimulus controller to handle the sorting functionality:
// app/javascript/sortable_controller.js
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs"
import { patch } from "@rails/request.js"
export default class extends Controller {
static values = { endpoint: String };
connect() {
Sortable.create(this.element,
{
draggable: "[draggable]",
animation: 250,
easing: "cubic-bezier(1, 0, 0, 1)",
ghostClass: "opacity-50",
onEnd: this.#updatePosition.bind(this)
}
)
}
// private
async #updatePosition(event) {
await patch(
this.endpointValue.replace(/__ID__/g, event.item.dataset.sortableIdValue),
{ body: JSON.stringify({ new_position: event.newIndex + 1 }) }
)
}
}
Refer to the Sortable documentation to understand the details, but in short [draggable]
is the attribute looked for for elements that need to be draggable. animation
and easing
are for smoother drag and drop transitioning. The ghostClass
is added to the element until its position is stored.
Then update the views to use this Stimulus controller:
# app/views/boards/_board.html.erb
-<ul class="flex w-full gap-x-8 overflow-x-auto mt-2 px-4">
+<ul data-controller="sortable" data-sortable-endpoint-value="<%= column_path(id: "__ID__") %>" class="flex w-full gap-x-8 overflow-x-auto mt-2 px-4">
<%= render board.columns %>
</ul>
# app/views/boards/_column.html.erb
-<li class="w-80 h-[calc(100dvh-4rem)] shrink-0 bg-gray-100 shadow-inner rounded-lg overflow-y-auto">
+<li draggable data-sortable-id-value="<%= column.id %>" class="w-80 h-[calc(100dvh-4rem)] shrink-0 bg-gray-100 shadow-inner rounded-lg overflow-y-auto">
<h2 class="sticky top-0 px-4 py-2 font-medium text-gray-800 bg-gray-100">
<%= column.name %>
</h2>
- <ul class="flex flex-col gap-y-2 px-4 pb-4">
+ <ul data-controller="sortable" data-sortable-endpoint-value="<%= card_path(id: "__ID__") %>" class="flex flex-col gap-y-2 px-4 pb-4">
<%= render column.cards %>
</ul>
</li>
# app/views/boards/_card.html.erb
-<li class="px-3 py-2 bg-white rounded-md shadow-xs">
+<li draggable data-sortable-id-value="<%= card.id %>" class="px-3 py-2 bg-white rounded-md shadow-xs">
<%= tag.p resource.title, class: "text-sm font-normal text-gray-900" %>
</li>
Also, create the controllers to handle the position updates:
# app/controllers/columns_controller.rb
class ColumnsController < ApplicationController
def update
Board::Column.find(params[:id]).update position: new_position
end
private
def new_position = params[:new_position]
end
# app/controllers/cards_controller.rb
class CardsController < ApplicationController
def update
Board::Card.find(params[:id]).update position: new_position
end
private
def new_position = params[:new_position]
end
(small aside: I would typically, especially for the cards controller, create a dedicated sortable
controller with an update action, to keep things extra tidy)
Now you can drag cards within their columns, and the positions will be saved to the database! Awesome! 🤩
Sorting Between Columns
The next step is to allow cards to be dragged between columns. Update the Stimulus controller:
// app/javascript/sortable_controller.js
import { patch } from "@rails/request.js"
export default class extends Controller {
- static values = { endpoint: String };
+ static values = { groupName: String, endpoint: String };
connect() {
Sortable.create(this.element,
{
+ group: this.groupNameValue,
draggable: "[draggable]",
animation: 250,
easing: "cubic-bezier(1, 0, 0, 1)",
// …
async #updatePosition(event) {
await patch(
- this.endpointValue.replace(/__ID__/g, event.item.dataset.sortableIdValue),
- { body: JSON.stringify({ new_position: event.newIndex + 1 }) }
+ this.endpointValue.replace(/__ID__/g, event.item.dataset.sortableIdValue), // the `sortableIdValue` works for both columns and cards
+ { body: JSON.stringify({ new_list_id: event.to.dataset.sortableListIdValue, new_position: event.newIndex + 1 }) }
)
}
}
And then update the column partial:
# app/views/boards/_column.html.erb
- <ul data-controller="sortable" data-sortable-endpoint-value="<%= card_path(id: "__ID__") %>" class="flex flex-col gap-y-2 px-4 pb-4">
+ <ul data-controller="sortable" data-sortable-group-name-value="column" data-sortable-list-id-value="<%= column.id %>" data-sortable-endpoint-value="<%%= card_path(id: "__ID__") %>" class="flex flex-col gap-y-2 px-4 pb-4 overflow-x-clip">
And update the cards controller to allow to update the column id:
# app/controllers/cards_controller.rb
class CardsController < ApplicationController
def update
- Board::Card.find(params[:id]).update position: new_position
+ Board::Card.find(params[:id]).update board_column_id: board_column_id, position: new_position
end
private
+ def board_column_id = params[:new_list_id]
+
def new_position = params[:new_position]
end
Now cards can be dragged between columns, and the changes will be saved to the database. Sweet! 🍭
And that's it! With just a small Stimulus controller (less than 30 lines of code!), you've created a fully functional Kanban board with drag-and-drop capabilities.
The polymorphic association for cards is a particularly elegant solution, I think. You can attach any type of resource to your cards, so you could essentially copy/paste these lines of code in any Rails app that need a kanban-like feature.
Top comments (1)
I plan to extend this article/kanban board with more features, let me know if there is something specific you like to see. 👇