執筆経緯
日本語のソースが少なく
以下の記事を参照してDrag&Dropを学びましたので感謝の気持ちを込めて翻訳致します。
Tutorial: build a drag-n-drop kanban board on Rails with SortableJS
再現した環境
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
アソシエーションを定義
class Kanban < ApplicationRecord
has_many :kanban_columns
end
class KanbanColumn < ApplicationRecord
belongs_to :kanban
has_many :cards
end
class Card < ApplicationRecord
belongs_to :kanban_column
end
Seedを作成
# 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
ここまでのセットアップでは生情報の作成まで完了してます!
以下のページが表示されます!
HTMLとCSSを編集する
kanbanの所有しているcardが表示されるようにHTMLを編集します。
併せて、いい感じにcardが表示さるようにcssを編集します。
...(省略)
<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>
...(省略)
.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;
}
...(省略)
@import "kanban";
HTMLとcssを編集した結果、以下のようにシンプルだけどわかりやすいデザインになりました!
特にcardにマウスカーソル当てると手で掴む・離す動作ができるようになったのがナイスですね♪
JSでアニメーションをつける
まず、ulの配列にSortableJSを適用した関数を作成します。
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を起動する記述をします
...(省略)
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列へ移動することができるようになってます!
しかし、cardを動かしても画面を更新すると元に戻ってしまいます。。。
バックエンド側の実装をしていきましょう♪
バックエンド側の実装手順
ここでは、バックエンド側の動きを理解しやすくするため2つのステップに分けて実装していきます。
STEP1: form_withを使用してhttp通信で保存する
STEP2: 超かっこいいAjaxを利用してcardを動かす度に逐次保存されるようにする
どのようにcardの位置を表現するのか
cardを動かした状態をDB上に保存するためには、cardの位置を表現する必要があります。
ここでは、columnのidとcardのidを以下のようにハッシュ形式で位置を表現します。
また、viewからDBにデータを引き渡すためにkanbanIdsという変数に格納してます。
kanbanIds = {
"columns": [
{ "id": 1, "itemIds": [2, 5, 6] },
{ "id": 2, "itemIds": [1] },
{ "id": 3, "itemIds": [4, 3] }
]
バックエンドSTEP1: form_withを使用してhttp通信で保存する
<% 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を修正します。
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アクションのルーティングを追記
...(省略)
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
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形式データを反映する動作をするようになりました♪
この状態で"Save Change"を押すと、Drag&Drop後の状態がDBに保存されるので
画面を更新しても状態が保存されるようになりました!
現状では、SaveChangeボタンを押さないと保存されないし、input要素とボタンで見栄えが悪くなってますね。。。
ですので次は、Drag&Dropした直後にAjaxで逐次保存されるように実装していきます。
バックエンドSTEP2:超かっこいいAjaxを利用してcardを動かす度に逐次保存されるようにする
このステップは凄く早く終わります♪
まず、form_withを非表示にします。
<% 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";の記述を忘れないように!)
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後の状態になるようになりました!!!!!
このチュートリアルを楽しんでもらえたら嬉しいです!
以下が完成品のソースコードとdemo版のurlになるので理解を深めるのにお役立て下さい。
終わりに
日本語ソースがなかったので困っていたところ、
yannkleinさんの記事に出会いとても丁寧でわかりやすいチュートリアルに救われました。
私の翻訳記事を通して少しでも技術の普及に貢献できれば幸いです。