Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

RailsとHotwireの「カンバン」ボードを拡張する: 続編(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

RailsとHotwireの「カンバン」ボードを拡張する: 続編(翻訳)

RailsとHotwireで「カンバン」ボードを構築する(翻訳)

上の前回記事では、30行足らずのStimulusコントローラでカンバンボードをさくっと作る方法を紹介しました。
しかし、肝心の新しいカードやカラムをカンバンボードに追加する機能がなければ、何の役にも立ちません。そこで今回はこの問題を解決することにしましょう。

今回の続編では、前回の実装をベースに3つの機能強化を行います。前回と同様、本記事で解説するコードは以下のGitHubリポジトリで公開しています。3つの機能を順に追加するたびに、カンバンボードの有用性が増していきます。

rails-designer-repos/cards - GitHub

🔗 1: 新しいカードやカラムを追加する機能

最初に、任意のカラム(列)でカードを新規作成する機能を追加しましょう。Turbo Streamを使えば驚くほどシンプルに実装できます。

まずは、CardsControllercreateアクションを追加します。

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 %>

訳注

dom_idについては以下の記事もどうぞ。

Rails: Action Viewのdom_idヘルパーは実は有能(翻訳)

ルーティングファイルの更新もお忘れなく。

# 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の真骨頂です。

新しいカラムを追加するときも、同じ要領でやれます。
今度はColumnsControllercreateアクションを追加します。

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-lrrotate-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プラグインがあります。

SortableJS/Sortable - GitHub

まず、再配置ロジックを専用のコントローラに切り出す形でリファクタリングします。カードの再配置もカラムの再配置も同じ振る舞いを必要とするので、ロジックをここに凝縮させるのが合理的です。さらに、ここで複数の項目を一度に移動できるようにします。

# 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の配列を受け取ると、それらを一括で更新します。

これで、CardsControllerColumnsControllerから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パラメータが設定されます。つまり、cardboard/cardsに、columnboard/columnsになるというわけです。これによって、RepositionControllerはどのモデルが対象化を認識してくれるようになります。

訳注
Railsガイドの以下の項目もどうぞ。

参考: §2.8 ルーティングの「concern」機能 -- Rails のルーティング - 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を作ったおかげで、これを使って他のアプリのソート可能リソースを楽に拡張できます。

関連記事

RailsとHotwireで「カンバン」ボードを構築する(翻訳)

Railsのコンポーネントを「gemなしで」シンプルに構築する(翻訳)

Rails: Action Viewのdom_idヘルパーは実は有能(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。