みなさん、Trello使ってますか?
IT企業は「TrelloとSlack使ってる」と言っておけばイケてるスタートアップ風を装えると誰かが言っていました。
KnockoutJS Advent Calendar 13日目ということで今回はTrelloライクなUIをKnockoutJSで実装してみたいと思います。
Trello
一応軽く説明すると、Trelloはタスクを書いた『カード』と、カードをステータスごとに格納する『リスト』から構成されているタスク管理ツールで、「リストにカードを追加・削除したり、異なるリストにカードを移動させる」というのが基本的な機能です。
例えばTODOリストに新しいタスクを書いたカードを追加して、そのタスクに取りかかるときにDOINGリストに移動し、タスクが終了したらDONEリストに移動するという流れで使います。
特徴的なのは、これらの動作をすべてドラッグ&ドロップで行う点です。「Webアプリ版ポストイット」と言えば分かりやすいでしょうか。
技術的な主題(と元ネタ)
Trello風UIをKnockoutJSで実装するポイントはなんでしょうか?
ドラッグ&ドロップ自体はjQueryUIで実装できますが、ドラッグ後の状態を永続化するには「リスト間のドラッグを検知してDB上に保存する」機能が必要になります。
KnockoutJSの場合で言うと、「ObservableArray間でのデータの受け渡しをどうやって検知して、永続化するか」が課題です。
ObservableArrayはKnockoutJSが持つ基本的なデータバインディング機能の一つで、「配列の状態を監視し、変更があればテンプレートにも反映する」というJSフレームワークによくあるベーシックな機能です。
ObservableArray自体が持つ関数もいくつかありますが、基本的にはJavaScriptのArrayからの拡張です。Push, Pop, Splice, とかとか。
なので、何かイベント(ボタンクリックとか)に対して要素を追加したり削除したり、ということは得意ですが、ObservableArray同士のデータの受け渡しをするには、何か工夫をする必要があります。
Google先生に相談したところ、以下の記事を見つけました。
上の記事では、KnockoutJSのbindingHandlerという仕組みを使い、ObervableArray同士のデータの受け渡しを可能にしています。
ドラッグ前の親リストとドラッグ後の親リストを保持し、さらにドロップされた新しいポジション(リスト内での順番)などの情報をもとに、ドラッグ前の親リストからドラッグした要素を削除して、ドラッグ後の親リストの正しい場所に要素を追加する、ということをしています。
今回はこの具体的な活用パターンとして、Trello風のWebアプリをさくっと作ってみよう、というわけです。
バックエンド
「ドラッグした後の状態をDBに永続化する」というのがポイントなので、簡単にでもサーバ側が必要になりますね。Railsでさくっと用意してしまいましょう。
プロジェクト名はTololoにしました。
$ rails new Tololo
$ cd Tololo
$ rails g model List name:string
$ rails g model Card list:references content:string
$ rake db:migrate
class List < ActiveRecord::Base
has_many :cards
accepts_nested_attributes_for :cards, allow_destroy: true, update_only: true
end
ここまででモデルのスキーマが完成です。
カードの作成とか編集機能は本記事の主題から外れるので今回は作らず、シードデータを予め用意しておきましょう。一風堂に行ったときのタスクをイメージしています。
# List の作成
todo = List.create(:name => "Todo")
doing = List.create(:name => "Doing")
done = List.create(:name => "Done")
# Cardのシードデータ
%w{ラーメン屋にいく もやしを食べる 注文する ラーメンを食べる 替え玉を食べる お会計を支払う}.each do |task|
Card.create(:list_id => todo.id, :content => task)
end
$ rake db:seed # シードデータの挿入
次は、コントローラとテンプレートを用意します。
$ rails g controller Tololo index
コントローラにはテンプレートを描画するindexアクションの他に、APIを叩いてカードを持ってくるlists、ドラッグが終了したときにPUTリクエストを送るupdateを用意します。
class TololoController < ApplicationController
protect_from_forgery except: [:update] # APIなので
def index
end
def lists
@lists = List.all
render :json => { :lists => @lists.to_json(:include => :cards) }
end
def update
@card = Card.find(params[:id])
if @card.update_attributes(card_params)
render :json => { :status => 'success' }
else
render :json => { :status => 'failure' }
end
end
private
def card_params
params.require(:card).permit(:list_id)
end
end
ルーティングも。
Rails.application.routes.draw do
root 'tololo#index'
resources :tololo, only: [:index, :update] do
collection do
get :lists
end
end
end
ここまででバックエンドは完成です。
フロントエンド
次にフロントエンドを作っていきましょう。
knockoutjs-rails, ドラッグUIを表現するjquery-ui、bootstrapを入れます。
# 以下を加筆
gem 'jquery-ui-rails'
gem 'knockoutjs-rails'
gem "therubyracer"
gem "less-rails" #Sprockets (what Rails 3.1 uses for its asset pipeline) supports LESS
gem "twitter-bootstrap-rails"
$ bundle install
$ rails generate bootstrap:install less #=> bootstrapの配置
$ rails generate bootstrap:layout application fluid #=> レスポンシブUIの枠組み
KnockoutJSを適用します。
//= require twitter/bootstrap
// 以下2行を加える
//= require jquery-ui
//= require knockout
...
さて、ここまででようやくTrelloUIを実装する土台ができました。あとはテンプレートとCoffeeScriptをいじるだけです。
$ ->
class List
constructor: (data) ->
self = this
self.name = data.name
self.cards = ko.observableArray(data.cards)
class TololoViewModel
constructor: ->
self = this
self.lists = ko.observableArray()
$.get('/tololo/lists', (result) ->
$.map(JSON.parse(result.lists), (list) ->
self.lists.push(new List(list))
)
)
ko.applyBindings new TololoViewModel()
<div class="row" data-bind="foreach: lists">
<div class="col-md-4">
<h2 data-bind="text: name"></h3>
<div data-bind="foreach: cards">
<div class="well" data-bind="text: content"></div>
</div>
</div>
</div>
ここで行っているのは、/tololo/lists をAPIとして叩いて、返ってきたデータをListインスタンスとして生成して、observableArrayに入れる、という作業です。
http://localhost:3000/ にアクセスしてTodoリストにカードが表示されていれば、KnockoutJSが正しく動いています。
次に漸くカードをドラッグして保存できるようにしますが、まずはドラッグを監視するところまでを実装します。
Listクラスの上に以下を追加します。引用の記事そのままですが、後でDBに更新処理を送るために少し変更を加えます。
ko.bindingHandlers.sortableItem = {
init: (element, valueAccessor) ->
options = valueAccessor()
$(element).data("sortItem", options.item)
$(element).data("parentList", options.parentList)
}
ko.bindingHandlers.sortableList = {
init: (element, valueAccessor, allBindingsAccessor, viewModel, context) ->
$(element).data("sortList", valueAccessor()) #attach meta-data
$(element).sortable({
connectWith: '.sortContainer',
update: (event, ui) ->
item = ui.item.data("sortItem")
return false if item.content == ''
if (item)
#identify parents
originalParent = ui.item.data("parentList")
newParent = ui.item.parent().data("sortList")
#figure out its new position
position = ko.utils.arrayIndexOf(ui.item.parent().children(), ui.item[0])
if (position >= 0)
originalParent.remove(item)
newParent.splice(position, 0, item)
ui.item.remove()
})
}
次に、テンプレートのsortContainer配下にsortbleListバインドとsortableItemバインドを追加します。
<div class="sortContainer" data-bind="foreach: cards, sortableList: cards">
<div class="well" data-bind="text: content,
sortableItem: { item: $data, parentList: $parent.cards }"></div>
</div>
そして、これは大変いけてないのですが今回やってる途中で配列の要素が0個だとドラッグできないということがわかったので、Listクラスに次のような変更を加えます。
class List
constructor: (data) ->
self = this
self.name = data.name
#self.cards = ko.observableArray(data.cards) =>
self.cards = ko.observableArray(if data.cards.length > 0 then data.cards else [{content: ''}])
ここまで実装すれば、フロントエンド上ではカードをドラッグすることができ、各List間のデータの受け渡しも監視できています。あとは変更があったときに更新のリクエストを送るだけです。
ここで上記の引用記事そのままだと少し問題があって、ドラッグしてデータ構造を終えたその瞬間、カードそれ自体のidと新しいlist_idをもとにデータを更新するように今回はなっていますが、現状だと親配列の情報があるのみでListオブジェクトの情報(list_idを得るため)がありません。そこで、若干無理矢理ですがテンプレートとスクリプトを以下のように変えます。
<div class="sortContainer" data-bind="foreach: cards, sortableList: $data">
<div class="well" data-bind="text: content,
sortableItem: { item: $data, parentList: $parent.cards }"></div>
</div>
sortableListでバインドするデータをcards => $data(= Listオブジェクト)に変えました。こうすることで、cardsのデータだけではなく親リストのidも取得できます。
$ ->
ko.bindingHandlers.sortableItem = { ... } # 変更なし
ko.bindingHandlers.sortableList = {
init: (element, valueAccessor, allBindingsAccessor, viewModel, context) ->
$(element).data("sortList", valueAccessor()) #attach meta-data
$(element).sortable({
connectWith: '.sortContainer',
update: (event, ui) ->
item = ui.item.data("sortItem")
if (item)
return false if item.content == ''
#identify parents
originalParent = ui.item.data("parentList")
newParent = ui.item.parent().data("sortList") # この中身がListオブジェクトに変わった
#figure out its new position
position = ko.utils.arrayIndexOf(ui.item.parent().children(), ui.item[0])
if (position >= 0)
originalParent.remove(item)
newParent.cards.splice(position, 0, item) # cardsをつける
ui.item.remove()
# 新しいlist_idを付けて保存
$.ajax({
type: 'PUT', url: '/tololo/' + item.id, contentType: 'application/json',
data: ko.toJSON({
card: { list_id: newParent.id } # idとってPUT
}),
success: (result) -> console.log result
})
})
}
ここまでミスなく実装すれば、無事にDBに保存されるTrelloライクなWebアプリができるはずです。
一つ決定的にいけてない部分(Listクラスのカードが0個の場合)はありますが、KnockoutJSでこういったアプリケーションが簡単に作れる、ということが伝わるととても嬉しいです。
ソースコード全体: https://github.com/yusuke-nozoe/Tololo
Herokuにアップロードしたやつ: https://knockout-tololo.herokuapp.com/
批判・コメント・アドバイスなどありましたらぜひ。