JavaScript
knockoutjs

KnowkoutJSでTrelloライクなWebアプリを作る

More than 3 years have passed since last update.

みなさん、Trello使ってますか?

IT企業は「TrelloとSlack使ってる」と言っておけばイケてるスタートアップ風を装えると誰かが言っていました。

KnockoutJS Advent Calendar 13日目ということで今回はTrelloライクなUIをKnockoutJSで実装してみたいと思います。

Trello

http://trello.com/

一応軽く説明すると、Trelloはタスクを書いた『カード』と、カードをステータスごとに格納する『リスト』から構成されているタスク管理ツールで、「リストにカードを追加・削除したり、異なるリストにカードを移動させる」というのが基本的な機能です。

例えばTODOリストに新しいタスクを書いたカードを追加して、そのタスクに取りかかるときにDOINGリストに移動し、タスクが終了したらDONEリストに移動するという流れで使います。

特徴的なのは、これらの動作をすべてドラッグ&ドロップで行う点です。「Webアプリ版ポストイット」と言えば分かりやすいでしょうか。

技術的な主題(と元ネタ)

Trello風UIをKnockoutJSで実装するポイントはなんでしょうか?

ドラッグ&ドロップ自体はjQueryUIで実装できますが、ドラッグ後の状態を永続化するには「リスト間のドラッグを検知してDB上に保存する」機能が必要になります。

KnockoutJSの場合で言うと、「ObservableArray間でのデータの受け渡しをどうやって検知して、永続化するか」が課題です。

ObservableArrayはKnockoutJSが持つ基本的なデータバインディング機能の一つで、「配列の状態を監視し、変更があればテンプレートにも反映する」というJSフレームワークによくあるベーシックな機能です。
ObservableArray自体が持つ関数もいくつかありますが、基本的にはJavaScriptのArrayからの拡張です。Push, Pop, Splice, とかとか。

なので、何かイベント(ボタンクリックとか)に対して要素を追加したり削除したり、ということは得意ですが、ObservableArray同士のデータの受け渡しをするには、何か工夫をする必要があります。

Google先生に相談したところ、以下の記事を見つけました。

http://www.knockmeout.net/2011/05/dragging-dropping-and-sorting-with.html

上の記事では、KnockoutJSのbindingHandlerという仕組みを使い、ObervableArray同士のデータの受け渡しを可能にしています。

ドラッグ前の親リストとドラッグ後の親リストを保持し、さらにドロップされた新しいポジション(リスト内での順番)などの情報をもとに、ドラッグ前の親リストからドラッグした要素を削除して、ドラッグ後の親リストの正しい場所に要素を追加する、ということをしています。

今回はこの具体的な活用パターンとして、Trello風のWebアプリをさくっと作ってみよう、というわけです。

バックエンド

「ドラッグした後の状態をDBに永続化する」というのがポイントなので、簡単にでもサーバ側が必要になりますね。Railsでさくっと用意してしまいましょう。

プロジェクト名はTololoにしました。

bash
$ rails new Tololo
$ cd Tololo
$ rails g model List name:string
$ rails g model Card list:references content:string
$ rake db:migrate
app/model/list.rb
class List < ActiveRecord::Base
  has_many :cards
  accepts_nested_attributes_for :cards, allow_destroy: true, update_only: true
end

ここまででモデルのスキーマが完成です。
カードの作成とか編集機能は本記事の主題から外れるので今回は作らず、シードデータを予め用意しておきましょう。一風堂に行ったときのタスクをイメージしています。

db/seed.rb
# 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
bash
$ rake db:seed # シードデータの挿入

次は、コントローラとテンプレートを用意します。

bash
$ rails g controller Tololo index

コントローラにはテンプレートを描画するindexアクションの他に、APIを叩いてカードを持ってくるlists、ドラッグが終了したときにPUTリクエストを送るupdateを用意します。

app/controllers/tololo_controller.rb
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

ルーティングも。

config/routes.rb
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を入れます。

Gemfile
# 以下を加筆
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"
bash
$ bundle install
$ rails generate bootstrap:install less #=> bootstrapの配置
$ rails generate bootstrap:layout application fluid #=> レスポンシブUIの枠組み

KnockoutJSを適用します。

app/assets/javascripts/application.js
//= require twitter/bootstrap
// 以下2行を加える
//= require jquery-ui
//= require knockout
...

さて、ここまででようやくTrelloUIを実装する土台ができました。あとはテンプレートとCoffeeScriptをいじるだけです。

app/assets/javascripts/tololo.js.coffee
$ ->
  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()
app/views/tololo/index.html.erb
<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に更新処理を送るために少し変更を加えます。

app/assets/javascripts/tololo.js.coffee
  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バインドを追加します。

app/views/tololo/index.html.erb
<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を得るため)がありません。そこで、若干無理矢理ですがテンプレートとスクリプトを以下のように変えます。

app/views/tololo/index.html.erb
<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も取得できます。

app/assets/javascripts/tololo.js.coffee
$ ->
  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/

批判・コメント・アドバイスなどありましたらぜひ。