SortableJSをRailsで使用して看板ボードをDrag&Dropで動かしてみよう

## 執筆経緯
日本語のソースが少なく
以下の記事を参照してDrag&Dropを学びましたので感謝の気持ちを込めて翻訳致します。

>[Tutorial: build a drag-n-drop kanban board on Rails with SortableJS](https://medium.com/le-wagon-tokyo/tutorial-build-a-drag-n-drop-kanban-board-on-rails-with-sortablejs-c6affa5642cf)

## 再現した環境
MacBook Air (M1, 2020)
Rails 6.1.6.1
ruby 3.0.4p208
yarn 1.22.19

※チュートリアル原文ままでは、私の環境で再現できなかったので
私の環境で再現できたコードに修正しております。

## Let's チュートリアル
### Setup
```:ターミナル
rails new \
  --database postgresql \
  -m https://raw.githubusercontent.com/lewagon/rails-templates/master/minimal.rb \
  dragndrop_kanban_app
```
### SortableJSを導入
```:ターミナル
yarn add sortablejs
```

### Kanban Modelを作成する
```:ターミナル
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
```

### アソシエーションを定義
```ruby:kanban.rb
class Kanban < ApplicationRecord
  has_many :kanban_columns
end
```
```ruby:kanban_column.rb
class KanbanColumn < ApplicationRecord
  belongs_to :kanban
  has_many :cards
end
```
```ruby:card.rb
class Card < ApplicationRecord
  belongs_to :kanban_column
end
```

※補足：テーブルは以下のような関連になってます
![Screen Shot 2022-09-02 at 14.36.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2646082/532ca84f-6aad-a0e3-40f1-11a1078710e5.png)

### Seedを作成
```ruby:db/seeds.rb
# dragndrop_kanban_app/db/seed.rb
Card.destroy_all
KanbanColumn.destroy_all
Kanban.destroy_all
my_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)
```
```:ターミナル
rails db:seed
```
### 現在の状態を見てみよう♪
長いセットアップお疲れ様でした！
では、今の状態を確認してみましょう！！

```:ターミナル
rails s
```
その後、以下にアクセスして下さい！
[http://localhost:3000/kanbans/1](http://localhost:3000/kanbans/1)

ここまでのセットアップでは生情報の作成まで完了してます！
以下のページが表示されます！
![Screen Shot 2022-09-02 at 14.47.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2646082/93fb3afd-af3e-8a7c-143b-c4fac3cd8101.png)

### HTMLとCSSを編集する
kanbanの所有しているcardが表示されるようにHTMLを編集します。
併せて、いい感じにcardが表示さるようにcssを編集します。

```erb: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>
...(省略)
```
```scss: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;
}
```
```scss:app/assets/stylesheets/components/_index.scss
...(省略)
@import "kanban";
```
HTMLとcssを編集した結果、以下のようにシンプルだけどわかりやすいデザインになりました！
特にcardにマウスカーソル当てると手で掴む・離す動作ができるようになったのがナイスですね♪

![Screen Shot 2022-09-02 at 18.39.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2646082/8f5db347-dbe7-cf0a-aea8-7b7d4714346b.png)
### JSでアニメーションをつける
まず、ulの配列にSortableJSを適用した関数を作成します。

```javascript:app/javascript/plugins/initSortable.js
import Sortable from 'sortablejs';
const initKanbanSortable = (ulElements) => {
  ulElements.forEach((ul) => {
    new Sortable(ul, {
        group: 'kanban', // set both lists to same group
        animation: 300
    });
  });
};
export { initKanbanSortable };
```
application.jsにinitKanbanSortableを起動する記述をします
```javascript:app/javascript/packs/application.js
...(省略)
import {initKanbanSortable} from '../plugins/initSortable'
...(省略)
document.addEventListener('turbolinks:load', () => {
  const kanbanUls = document.querySelectorAll(".kanban .kanban-col");
  if (kanbanUls) {
    initKanbanSortable(kanbanUls);
  }
});
```
ここまでの実装で、cardをDrag&Dropでkanban列から他のkanban列へ移動することができるようになってます！
![Screen Shot 2022-09-02 at 23.15.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2646082/41a36f21-4889-b701-c20f-5068b02959cb.png)

しかし、cardを動かしても画面を更新すると元に戻ってしまいます。。。
バックエンド側の実装をしていきましょう♪
### バックエンド側の実装手順
:::note info
ここでは、バックエンド側の動きを理解しやすくするため2つのステップに分けて実装していきます。

STEP1: form_withを使用してhttp通信で保存する
STEP2: 超かっこいいAjaxを利用してcardを動かす度に逐次保存されるようにする
:::

### どのようにcardの位置を表現するのか
cardを動かした状態をDB上に保存するためには、cardの位置を表現する必要があります。
ここでは、columnのidとcardのidを以下のようにハッシュ形式で位置を表現します。
また、viewからDBにデータを引き渡すためにkanbanIdsという変数に格納してます。
```:card位置の表現方法
kanbanIds = { 
  "columns": [ 
    { "id": 1, "itemIds": [2, 5, 6] }, 
    { "id": 2, "itemIds": [1] }, 
    { "id": 3, "itemIds": [4, 3] } 
  ] 
```
![Screen Shot 2022-09-02 at 18.39.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2646082/4f594778-2cf3-3692-ad60-1613ab56f762.png)

### バックエンドSTEP1: form_withを使用してhttp通信で保存する
```erb:app/views/kanbans/show.html.erb
<% if true %>
  <%= form_with url: kanban_sort_path, method: :patch do |f|%>
    <%= f.text_field 'kanban[kanbanIds]', class: "kanban-form-input" ,value: ""%>
    <%= f.submit "Saved changes" %>
  <% end %>
<% end %>
<div class="kanban" data-id="<%= @kanban.id %>">
  <% @kanban.kanban_columns.each do |column| %>
    <ul class="kanban-col" data-colid=<%= 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-itemid=<%= item.id %> >
          <%= item.content %>
        </li>
      <% end %>
    </ul>
  <% end %>
</div>
```
次に、Drag&Dropが終了した時点でform_withのinput部に移動後cardの位置情報を書き込むようにinitSortable.jsを修正します。
```javascript:app/javascript/plugins/initSortable.js
import 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 saveKanban = (ulElements) => {
  const kanbanForm = document.querySelector(".kanban-form-input");
  // 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 };
```
ここでちょっとだけinitSortable.jsについて説明します。

1: saveKanban関数について
Drag&Dropで移動動作が起きた後に、kanbanのul要素全ての位置情報を保存するために定義した関数です。
このSTEPでは、kanbanForm.value(form_withのinput)にJSON形式で格納します。

2: saveKanbanBindedについて
これはsaveKanban関数にulElements(kanbanのul要素)をbindするために定義しました。
言い方を変えると、saveKanbanBindedを定義することにより、saveKanban関数にulElementsを引数として指定している状態と等価なので、ulElementsを引数として引き渡す必要がなくなります。

3: saveKanbanBindedをSortableで呼び出す
SortableのonEnd属性を利用して、Drag&Dropが完了した時にsaveKanbanBindedが発火するように指定します。

最後に、controllerとroutes内の以下を修正します。

/controller
・show.html.erbに定義したsortアクションという新しいmethodを定義
・kanbanIdsをparameterとして受け取れるようにstrongパラメーターを修正

/routes
・sortアクションのルーティングを追記

```ruby: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
```
```ruby:config/routes.rb
Rails.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
```
ここまでの実装で以下のように
Drag&Drop後にinput要素へcard位置を示すJSON形式データを反映する動作をするようになりました♪

![Screen Shot 2022-09-03 at 10.26.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2646082/e4b26977-8b69-706f-8d48-7c88e9f5e5e9.png)

この状態で"Save Change"を押すと、Drag&Drop後の状態がDBに保存されるので
画面を更新しても状態が保存されるようになりました!

現状では、SaveChangeボタンを押さないと保存されないし、input要素とボタンで見栄えが悪くなってますね。。。
ですので次は、Drag&Dropした直後にAjaxで逐次保存されるように実装していきます。

### バックエンドSTEP2:超かっこいいAjaxを利用してcardを動かす度に逐次保存されるようにする
このステップは凄く早く終わります♪

まず、form_withを非表示にします。

```erb: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" ,value: ""%>
    <%= f.submit "Saved changes" %>
  <% end %>
<% end %>
...(省略)
```
次に、initSortable.jsにRails Ajaxを加えます。
(import Rails from "@rails/ujs";の記述を忘れないように！)
```javascript:app/javascript/plugins/initSortable.js
import Sortable from 'sortablejs';
import Rails from "@rails/ujs";

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 saveKanban = (ulElements) => {
  const kanbanForm = document.querySelector(".kanban-form-input");
  // 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 };
```
お疲れ様です！
これで完成しました！
Drag&Drop後にcard位置が逐次保存され、画面を更新してもDrag&Drop後の状態になるようになりました！！！！！

<iframe width="560" height="315" src="https://www.youtube.com/embed/UxXqG0Kfej8" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

このチュートリアルを楽しんでもらえたら嬉しいです！

以下が完成品のソースコードとdemo版のurlになるので理解を深めるのにお役立て下さい。
>[原文著者Github](https://github.com/yannklein/dragndrop_kanban_app)
>[demo_app(原文著者作)](https://dragndrop-kanban-app.herokuapp.com/kanbans/1)

## 終わりに
日本語ソースがなかったので困っていたところ、
yannkleinさんの記事に出会いとても丁寧でわかりやすいチュートリアルに救われました。
私の翻訳記事を通して少しでも技術の普及に貢献できれば幸いです。