RailsとHotwireの「カンバン」ボードを拡張する: 続編(翻訳)
上の前回記事では、30行足らずのStimulusコントローラでカンバンボードをさくっと作る方法を紹介しました。
しかし、肝心の新しいカードやカラムをカンバンボードに追加する機能がなければ、何の役にも立ちません。そこで今回はこの問題を解決することにしましょう。
今回の続編では、前回の実装をベースに3つの機能強化を行います。前回と同様、本記事で解説するコードは以下のGitHubリポジトリで公開しています。3つの機能を順に追加するたびに、カンバンボードの有用性が増していきます。
🔗 1: 新しいカードやカラムを追加する機能
最初に、任意のカラム(列)でカードを新規作成する機能を追加しましょう。Turbo Streamを使えば驚くほどシンプルに実装できます。
まずは、CardsControllerにcreateアクションを追加します。
class CardsController < ApplicationController
+ def create
+ @card = Board::Card.create board_column_id: params[:column_id], resource: Message.create(title: "New message")
+ end
+
def update
Board::Card.find(params[:id]).update board_column_id: board_column_id, position: new_position
end
次に、カラムのパーシャルの下部に「Add new card」ボタンを追加します。
# app/views/boards/_column.html.erb
-<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">
+<li draggable data-sortable-id-value="<%= column.id %>"class="relative 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 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">
+ <ul id="<%= dom_id(column) %>" 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">
<%= render column.cards %>
<li class="hidden px-3 py-2 bg-gray-200 rounded-md only:block">
<p class="text-sm font-normal text-gray-600">No cards here...</p>
</li>
</ul>
+
+ <div class="sticky bottom-0 px-4 py-2 bg-gray-100/90">
+ <%= button_to "Add new card", cards_path, params: {column_id: column}, class: "block py-1 bg-gray-200 w-full text-sm font-medium text-gray-800 rounded-md hover:bg-gray-300" %>
+ </div>
</li>
上のerbで、ul要素にdom_id(column)メソッドでidを追加していることにご注目ください。このidは、Turbo Streamが新規カードをどこに追加するかを認識するのに必要です。これに対するTurbo Streamのレスポンスは、以下のように実にシンプルです。
<%= turbo_stream.append @card.column, @card %>
ルーティングファイルの更新もお忘れなく。
# config/routes.rb
Rails.application.routes.draw do
resources :columns, only: %w[update]
- resources :cards, only: %w[update]
+ resources :cards, only: %w[create update]
root to: "pages#show"
end
これで、ボタンをクリックすれば新しいカードがカラムの最下部に即座に追加されるようになります。ページの更新は不要です。これこそHotwireの真骨頂です。
新しいカラムを追加するときも、同じ要領でやれます。
今度はColumnsControllerにcreateアクションを追加します。
class ColumnsController < ApplicationController
+ def create
+ @column = Board::Column.create board_id: params[:board_id], name: "New column"
+ end
+
def update
Board::Column.find(params[:id]).update position: new_position
end
boardパーシャルで新しい"Add new column"ボタンを追加するには、少しだけリファクタリングが必要です。
# app/views/boards/_board.html.erb
<h1 class="mx-4 mt-2 text-lg font-bold text-gray-800">
<%= board.name %>
</h1>
-<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>
+<div class="flex overflow-x-auto gap-x-6 mt-2 px-4 [--column-height:calc(100dvh-4rem)]">
+ <ul id="<%= dom_id(board) %>" data-controller="sortable" data-sortable-endpoint-value="<%= column_path(id: "__ID__") %>" class="flex gap-x-8">
+ <%= render board.columns %>
+ </ul>
+
+ <%= button_to "Add new column", columns_path, params: {board_id: board}, form: {class: "shrink-0 [writing-mode:vertical-lr] rotate-180"}, class: "block h-[var(--column-height)] py-1 text-sm font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200" %>
+</div>
ここでは、カラムやボタンの高さがバラつかないようにするために、--column-height CSSカスタムプロパティも導入しています。
"Add new column"ボタンを縦書きテキスト(横倒し)で表示すれば、横方向のスペースを節約できます。ここではwriting-mode: vertical-lrとrotate-180を指定することでテキストを縦書き表示にし、カンバンボードの右側で自然に表示できるようにしています(writing-modeなしでも必要なスペースは確保できますが)。
対応するcolumnパーシャルも、この新しいCSS変数を使うように更新します。
# app/views/boards/_column.html.erb
-<li draggable data-sortable-id-value="<%= column.id %>"class="relative 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="relative w-80 h-[var(--column-height)] 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>
Turbo Streamのレスポンスも同じパターンで書けます。
<%= turbo_stream.append @column.board, @column %>
ルーティングも同様に更新しましょう。
# config/routes.rb
Rails.application.routes.draw do
- resources :columns, only: %w[update]
+ resources :columns, only: %w[create update]
resources :cards, only: %w[create update]
root to: "pages#show"
end
RailsとHotwireで書ける基本的なパターンは、どれもよくできていますよね?
🔗 2: 複数のカードをまとめてドラッグアンドドロップする機能
ここから面白くなってきます。
複数のカードをまとめて移動したくなったらどうすればよいでしょうか?例のSortableJSには、これをうまく処理できるMultiDragプラグインがあります。
まず、再配置ロジックを専用のコントローラに切り出す形でリファクタリングします。カードの再配置もカラムの再配置も同じ振る舞いを必要とするので、ロジックをここに凝縮させるのが合理的です。さらに、ここで複数の項目を一度に移動できるようにします。
# app/controllers/reposition_controller.rb
class RepositionController < ApplicationController
def update
resources.each_with_index do |resource, index|
resource.update!({
board_column_id: params[:board_column_id],
position: params[:new_position].to_i
}.compact_blank)
end
end
private
def resources
resource_class = params[:resource_name].singularize.classify.constantize
resource_class.where(id: Array(params[:ids]))
end
end
このコントローラは汎用性が高いので、カードもカラムも同じように扱えます。IDの配列を受け取ると、それらを一括で更新します。
これで、CardsControllerとColumnsControllerからupdateアクションをめでたく削除できるようになりました。
# app/controllers/cards_controller.rb
class CardsController < ApplicationController
def create
@card = Board::Card.create board_column_id: params[:column_id], resource: Message.create(title: "New message")
end
- def update
- 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
# app/controllers/columns_controller.rb
class ColumnsController < ApplicationController
def create
@column = Board::Column.create board_id: params[:board_id], name: "New column"
end
- def update
- Board::Column.find(params[:id]).update position: new_position
- end
- private
- def new_position = params[:new_position]
end
コードの削除はいいものですね!🎉
さて、ルーティングをきれいに更新するには、Railsのちょっとした「マジック」にお出ましいただく必要があります。
ここではルーティングでconcernを使って、カードとカラムの両方にrepositionというエンドポイントを追加しています。
# config/routes.rb
Rails.application.routes.draw do
- resources :columns, only: %w[create update]
- resources :cards, only: %w[create update]
+ concern :reposition do
+ collection do
+ patch :reposition, controller: "reposition", action: "update", defaults: { resource_name: "board/#{@scope.frame.dig(:controller)}" }
+ end
+ end
+
+ resources :columns, only: %w[create update], concerns: :reposition
+ resources :cards, only: %w[create update], concerns: :reposition
root to: "pages#show"
end
ルーティングのconcernは気の利いた便利技です。このconcernをincludeした任意のリソースにrepositionルーティングが追加されるようになります。
defaultsハッシュには、コントローラ名に応じて自動的にresource_nameパラメータが設定されます。つまり、cardはboard/cardsに、columnはboard/columnsになるというわけです。これによって、RepositionControllerはどのモデルが対象化を認識してくれるようになります。
訳注
Railsガイドの以下の項目もどうぞ。
@scope.frame.dig(:controller)は、また別のRails「マジック」で、ルーティングコンテキスト内で現在のコントローラ名を取得できます。
次にStimulusコントローラも更新しましょう。
ここでSortableJSのMultiDragプラグインをインポートして、複数項目を選択可能にします。
# app/javascript/controllers/sortable_controller.js
import { Controller } from "@hotwired/stimulus"
-import Sortable from "sortablejs"
+import { Sortable, MultiDrag } from "sortablejs"
import { patch } from "@rails/request.js"
+Sortable.mount(new MultiDrag())
+
export default class extends Controller {
- static values = { groupName: String, endpoint: String };
+ static values = { groupName: String, endpoint: String, multiDraggable: Boolean };
connect() {
Sortable.create(this.element,
{
group: this.groupNameValue,
draggable: "[draggable]",
animation: 250,
easing: "cubic-bezier(1, 0, 0, 1)",
ghostClass: "opacity-50",
+ selectedClass: "selected",
- onEnd: this.#updatePosition.bind(this)
+ multiDrag: this.multiDraggableValue,
+ multiDragKey: "shift",
+
+ onEnd: (event) => this.#updatePosition(event)
}
)
}
// private
async #updatePosition(event) {
+ const items = event.items?.length > 0 ? event.items : [event.item]
+ const ids = items.map(item => item.dataset.sortableIdValue)
+
await patch(
- this.endpointValue.replace(/__ID__/g, event.item.dataset.sortableIdValue),
- { body: JSON.stringify({ new_list_id: event.to.dataset.sortableListIdValue, new_position: event.newIndex + 1 }) }
+ this.endpointValue,
+ {
+ body: JSON.stringify({
+ ids: ids,
+ board_column_id: event.to.dataset.sortableListIdValue,
+ new_position: event.newIndex + 1
+ })
+ }
)
}
}
multiDragKeyに"shift"を設定したので、Shiftキーを押しながらカードをクリックすることで、複数のカードを選択できるようになります。selectedClassは、選択項目にシンプルな外枠を表示して、選択中であることを示します。
/* app/assets/tailwind/application.css */
@import "tailwindcss";
@layer utilities {
.selected {
@apply ring ring-gray-400;
}
}
SortableJSは複数のCSSクラスの追加をサポートしていないため、上の小さなユーティリティクラスが必要になります(tailwindの場合)。
最後にビューを更新して新しいエンドポイントを利用できるようにし、カードのマルチドラッグ機能を有効にしましょう。
# app/views/boards/_board.html.erb
<h1 class="mx-4 mt-2 text-lg font-bold text-gray-800">
<%= board.name %>
</h1>
<div class="flex overflow-x-auto gap-x-6 mt-2 px-4 [--column-height:calc(100dvh-4rem)]">
- <ul id="<%= dom_id(board) %>" data-controller="sortable" data-sortable-endpoint-value="<%= column_path(id: "__ID__") %>" class="flex gap-x-8">
+ <ul id="<%= dom_id(board) %>" data-controller="sortable" data-sortable-endpoint-value="<%= reposition_columns_path %>" class="flex gap-x-8">
<%= render board.columns %>
</ul>
<%= button_to "Add new column", columns_path, params: {board_id: board}, form: {class: "shrink-0 [writing-mode:vertical-lr] rotate-180"}, class: "block h-[var(--column-height)] py-1 text-sm font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200" %>
</div>
# app/views/boards/_column.html.erb
<li draggable data-sortable-id-value="<%= column.id %>"class="relative w-80 h-[var(--column-height)] 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 id="<%= dom_id(column) %>" 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">
+ <ul id="<%= dom_id(column) %>" data-controller="sortable" data-sortable-group-name-value="column" data-sortable-list-id-value="<%= column.id %>" data-sortable-multi-draggable-value="true" data-sortable-endpoint-value="<%= reposition_cards_path %>" class="flex flex-col gap-y-2 px-4 pt-0.25 pb-4 overflow-x-clip">
<%= render column.cards %>
<li class="hidden px-3 py-2 bg-gray-200 rounded-md only:block">
<p class="text-sm font-normal text-gray-600">No cards here...</p>
</li>
</ul>
<div class="sticky bottom-0 px-4 py-2 bg-gray-100/90">
<%= button_to "Add new card", cards_path, params: {column_id: column}, class: "block py-1 bg-gray-200 w-full text-sm font-medium text-gray-800 rounded-md hover:bg-gray-300" %>
</div>
</li>
これで、Shiftキーを押しながらカードをクリックすれば、複数のカードを一度に別のカラムに移動できるようになりました。かなりいい感じになりましたよね?😎
以上で改善はおしまいです!
これでカンバンボードの機能がすべて揃いました。カードやカラムをその場で追加する機能はもちろん、複数のカードを移動する機能まで完成しました。
ルーティングのconcernパターンのおかげでルーティングがDRYになり、専用のRepositionControllerを作ったおかげで、これを使って他のアプリのソート可能リソースを楽に拡張できます。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。