RailsとHotwireで「カンバン」ボードを構築する(翻訳)
これは、どういうわけか「記事バックログ置き場」で長らく塩漬けになっていました。ドラッグアンドドロップ機能の構築はこれまで何度も手掛けてきましたが、最近ある新規顧客のカスタムUI開発をお手伝いした ときに、カンバン的な機能が作業リストに含まれていました。そこで、RailsとHotwireでこうした機能を構築するための資料を改めてあさってみたところ、私の手法は実にクリーンに書けることがわかったので、これを紹介する記事を書いてみてもよさそうだと思ったのです。
本記事で構築したいのは、以下のような機能です。
このソリューションでは、以下の3つの主要なコンポーネントを活用して楽に構築しています。
- SortableJS: ドラッグアンドドロップで欲しい機能を自分で書かずに構築できる頼もしいライブラリです。
- Rails Request.js: Stimulusコントローラから行う必要のあるリクエストがあるときは、この小さなパッケージを使っています。特にCSRFを回避するときに便利です。
- Positioning: リソースの位置を保存したいときに使うgemです(過去記事でも取り上げました)
これらをStimulusコントローラに「パッケージ化」しても、コードは30行にもなりません!🤯
それでは、私がこのような機能をどんなふうに構築していくかをお見せいたします。いつものようにGitHubリポジトリですべてのコードを参照できます。
🔗 データモデル
最初に、カンバンボード用の基本的なモデルを作成します。
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はpositioning gemの機能です。
Active Modelのto_partial_pathメソッドは、ファイル構造を簡潔に扱える興味深い機能です。これによって、Railsはboard/columns/_column.html.erbではなくboards/_column.html.erbを探索するようになります。このクリーンなパスの使い方については後述します。
Cardモデルではポリモーフィック関連付けを使っています。
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
このポリモーフィックなリレーションシップは、既存のアプリ(既存のリソースを「カンバン」ボードに追加する必要があるアプリ)に同様の機能を組み込むときに有用なアイデアですが、Railsの中級開発者でもこの素晴らしさを見落としがちです。
この例ではたまたまMessageモデルを使っていますが、「タスク」「チケット」など任意のモデルをカンバンボードに追加できます。さらに複雑な場合は、RailsのDelegatedTypeも利用できます↓。
🔗 基本的なUIを追加する
ここでは記事を手早く書くためにTailwind CSSを使っていますが、必要でしたら~~本物の~~普通のCSSを使っても構いません。✌️
カンバンボードのパーシャル、カラムのパーシャル、そしてカードのパーシャルをそれぞれ作成します。
<%# 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>
ここでrender board.columnsと書いていることにお気づきでしょうか?このrenderメソッドは、デフォルトでは昔ながらのapp/views/board/columns/_column.html.erbを探索しますが、カラムのモデルとカードのモデルではこの部分をto_partial_pathメソッドで更新してあるので、app/views/board/_{column,card}.html.erbを探索してくれます。従来よりもずっと簡潔ですね!
🔗 カラムとカードを並べ替える
お楽しみはここからです。カードをカラム内で並べ替え可能にするために、SortableJSとRailsのRequest.jsを使います。
bin/importmap pin sortablejs @rails/request.js
最初に、ソート機能を扱うStimulusコントローラを書きます。
// 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 }) }
)
}
}
詳しくはSortableJSのドキュメントを参照していただきたいのですが、手短に説明すると、[draggable]は、ドラッグ可能にする必要のある要素を探索するための属性です。
animationとeasingは、ドラッグアンドドロップがスムーズに遷移するための指定です。
ghostClassは、要素の位置が保存されるまで要素に追加されるCSSクラスです。
次に、ビューを更新してこのStimulusコントローラを使うようにします。
# 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>
要素位置の更新を処理するためのコントローラも作成します。
# 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
余談
私なら、特にCardsControllerのようなコントローラでは、updateアクションを備えた「ソート可能な」Railsコントローラを別途作成して、コードをさらに整理するでしょう。
これで、カードをカラム内でドラッグすると、カードの位置がデータベースに保存されるようになります。素晴らしいですね!🤩
🔗 カラム間の移動後にソートする
次のステップでは、カードをあるカラムから別のカラムに移動できるようにします。
Stimulusコントローラを以下のように更新します。
// 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 }) }
)
}
}
カラムのパーシャルも同様に更新します。
# 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">
RailsのCardsControllerも更新して、カラム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
これで、カードを別のカラムにドラッグできるようになり、変更結果がデータベースに保存されるようになりました。いいですね!🍭
以上でできあがりです!
30行にも満たない小さなStimulusコントローラーだけで、ドラッグアンドドロップ機能を備えた完全に機能するカンバンボードを構築できました。
特に、カードをポリモーフィック関連付けで扱うというソリューションは我ながらエレガントだと自負しています。
このカードにはどんなリソースでも関連付けられるので、既存のRailsアプリでカンバン的な機能が欲しくなったら、基本的にこのコードをコピペするだけで使えるようになります。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
参考: カンバン - Wikipedia