読者です 読者をやめる 読者になる 読者になる

Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

RailsでAngularJSを使ってTodoアプリを作成 - 9. AngularJS + Railsでソート可能(Sortable)なリストを作成する

AngularJS Rails中級 連載

f:id:nipe880324:20150112194806p:plain

「RailsでAngularJSを使ってTodoアプリを作成」の連載9回目です。今回で最終回です。
RailsとAngularJSでAngularJSのコントローラー, ngディレクティブ、ng-resource、ng-route、Railsのransack, kaminariなどのgemとの連携などいろいろなことをやってきました。

今回は、ng-sortableacts_as_listを使い「Todoをソートできる」ようにして、終わりとします。

動作確認

  • Rails 4.2.0
  • AngularJS 1.3.8
  • Bootstrap 3.3.1
  • UI Bootstrap 0.12.0
  • ransack 1.6.2
  • kaminari 0.16.1
  • mk-eitablespan 1.0.0
  • ng-sortable 1.1.9

目次

  1. ng-sortableのインストール
  2. ng-sortableの使い方
  3. AngularJS側でソート操作をできるようにする
  4. Rails側でacts_as_listを追加し、ポジションを保持する
  5. AngularJS側で各Todoのポジションを更新する


1. ng-sortableのインストール

ng-sortable - GitHubのreleasesから1.1.9のファイルを圧縮ファイルでダウンロードします。

解凍したファイルのdist配下の次の2つのファイルをRailsプロジェクトに追加します。

cp ~/Downloads/ng-sortable-1.1.9/dist/ng-sortable.min.js vendor/assets/javascripts/.
cp ~/Downloads/ng-sortable-1.1.9/dist/ng-sortable.min.css vendor/assets/stylesheets/.


そして、Railsがこれらのファイルをインポートするようにrequireを追記します。
application.jsに追加します。

// app/assets/javascripts/application.js

//= require ng-sortable.min


また、application.cssにも追加します。

/* app/assets/stylesheets/application.css */

 *= require ng-sortable.min


そして、AngularJSのdependencyにも追加します。

# app/assets/javascripts/app.coffee

app = angular.module('sampleApp', ['ui.bootstrap', 'ngResource', 'ngRoute', 'mk.editablespan', 'ui.sortable'])


これで、ng-sortableを追加することができました。




2. ng-sortableの使い方

ng-sortableを実際に使う前に簡単に使い方の説明をします。

ng-sortableのディレクティブは次の3つを使う必要があります。

  • as-sortable - アイテムのリストの要素に指定する
  • as-sortable-item - ソートやドラッグをしたいアイテムの要素に指定する
  • as-sortable-item-handle - アイテム内のドラッグを行いたい要素に指定する


具体的な例のHTMLは次のようになります。

<ul data-as-sortable="sortControlListeners" data-ng-model="items">
   <li data-ng-repeat="item in items" data-as-sortable-item>
      <div data-as-sortable-item-handle>{{ item.name }}</div>
   </li>
</ul>


これで、div要素がドラッグ可能になり、ソートができるようになります。


そして、data-as-sortableで指定したオプションでコールバック関数やドラッグが可能かどうかを次のように設定します。

$scope.dragControlListeners = {
    // アイテムがドラッグ可能かどうか制御したい場合オーバーライドして、 boolean値で返す(デフォルト値: true (ドラッグ可能))
    accept: function (sourceItemHandleScope, destSortableScope) {return boolean}

    // アイテムが別のリストにドラッグ&ドロップされた場合に呼び出されるコールバック関数(今回は使わない)
    itemMoved: function (event) {//Do what you want},

    // アイテムが同一のリストでドラッグ&ドロップ(ソート)された場合に呼び出されるコールバック関数
    orderChanged: function(event) {//Do what you want},
};



3. AngularJS側でソート操作をできるようにする

では、Todoをソートできるようにしていきます。

まずは、TodoリストのHTMLを修正します。HTMLの修正があった行に*を追加しています。

<!-- app/views/templates/todo_list.html.erb -->
  ...
* <ul data-as-sortable="sortListeners"data-ng-model="list.todos" class="list-group">
*    <li ng-repeat="todo in list.todos" ng-class="{completed: todo.completed}" data-as-sortable-item class="list-group-item">
      <div class="todo-completed">
        <input type="checkbox" ng-model="todo.completed" ng-click="toggleTodo(todo)">
      </div>
*      <div class="todo-description" data-as-sortable-item-handle>
        <editablespan
          model="todo.description"
          on-ready="todoDescriptionEdited(todo)"></editablespan>
      </div>
      <div class="todo-buttons pull-right">
        <button class="btn btn-danger btn-xs pull-right" type="button" ng-click="deleteTodo(todo)">
          <span class="glyphicon glyphicon-ban-circle"></span>
        </button>
      </div>
    </li>
  </ul>
  ...


そして、ng-sortableの使い方に則り、TodoListCtrlにソートした時のコールバック関数(orderChanged)を定義します。

# app/assets/javascripts/controllers/TodoListCtrl.coffee

$scope.sortListeners = {
  orderChanged: (event) ->
    console.log "sorted: #{event.dest.index}"
}


では、ここまででとりあえず動作確認をします。
ソートの動作ができるようになっていると思います。
f:id:nipe880324:20150124181557j:plain:w480


また、Javascriptコンソールにtodoの移動後のポジション番号が表示されます。
f:id:nipe880324:20150124181604j:plain:w480




まだ、サーバーにデータを送っていないので、移動後のポジション番号を送るようにします。

# app/assets/javascripts/controllers/TodoListCtrl.coffee

$scope.positionChanged = (todo) ->
  # TodoサービスクラスでTodoを更新する
  # サーバーには、params = { "todo" => {"target_position"=>2}, "todo_list_id"=>"1", "id"=>"5"} が送られる。
  @todoService.update(todo, target_position: todo.position)

$scope.sortListeners = {

  orderChanged: (event) ->
    # 移動後のポジション番号を取得
    newPosition   = event.dest.index + 1
    # 移動させたTodoモデルを取得
    todo          = event.source.itemScope.modelValue
    # 移動させたTodoのpositionを更新(Angular内部)
    todo.position = newPosition
    # 移動させたTodoのpositionを更新(サーバーへ送信)
    $scope.positionChanged(todo)
}

Todoサービスクラスのupdateメソッドは既に実装してますので、AngularJS側での処理は終わりです。



4. Rails側でacts_as_listを追加し、ポジションを保持する

acts_as_listというリストのソートや順序付けを簡易にするgemをいれます。

# Gemfile

gem 'acts_as_list'


Bundlerを実行します。

bundle install


次に、acts_as_listpositionカラムを使い、順序番号を保持することを前提に動くので、positionカラムをTodoに追加するマイグレーションファイルを作成し、実行します。

bin/rails g migration add_position_to_todos position:integer
bin/rake db:migrate

もちろんモデル内でacts_as_listを呼び出すときに:columnオプションを指定することで、別のカラム名を使うことも可能です


そして、モデルにacts_as_listメソッドを追加します。

  # app/models/todo_list.rb
  class TodoList < ActiveRecord::Base
    # todosの順序をpositionの昇順にする(1, 2, 3, ...)
*   has_many :todos, -> { order "position" }, dependent: :destroy

    validates :name, presence: true
  end


  # app/models/todo.rb
  class Todo < ActiveRecord::Base
    paginates_per 10

    belongs_to :todo_list

    # scope: :todo_list - todo_list内でTodoをリスト操作する
    # add_new_at: :top - 新規のTodoを一番上に追加する(defult: :bottom (一番下))
+   acts_as_list scope: :todo_list, add_new_at: :top

    validates :todo_list_id, presence: true
    validates :description,  presence: true, length: { maximum: 255 }
    validates :completed, inclusion: { in: [true, false] }

    # AngularJSからtarget_postionとして値が送られてくるのでゲッターメソッドを定義する
    # 内部的には、acts_as_listのinsert_at(integer)メソッドを呼ぶ
    # insert_atメソッドはTodoをリスト内の特定の箇所に移動し、それ以外のTodoのpositionをいい感じに更新してくれる
+   def target_position=(value)
+     insert_at(value.to_i)
+   end
  end


これで、たくさんのpositionを自動的に更新してくれるメソッドが使えるようになります。
挙動を確認してみましょう。少し複雑なので理解できなくても問題ありません。

$ bin/rails c -s
# リストを作成
list = TodoList.create(name: 'sample todo')


## 作成時の acts_as_listの挙動 ##
# 3つTodoを作成する
list.todos.create(description: 'todo1', completed: false)
list.todos.create(description: 'todo2', completed: false)
list.todos.create(description: 'todo3', completed: false)

# todoを確認する
list.todos
#=> <ActiveRecord::Associations::CollectionProxy
#   [#<Todo description: "todo3", position: 1, ...>,
#    #<Todo description: "todo2", position: 2, ...>,
#    #<Todo description: "todo1", position: 3, ...>]>

# 自動的に良い感じにpositionが更新されています。
# これは、createメソッドでTodoをINSERTする前に、acts_as_listが次のUPDATE文も実行しているためです。
# UPDATE "todos" SET position = (position + 1) WHERE ("todos"."todo_list_id" = 1)



## 更新時の acts_as_listの挙動 ##
# 1番目(todo3)を3番目に移動させます
list.todos.first.insert_at(3)

# todoを確認する
list.todos
#=> <ActiveRecord::Associations::CollectionProxy
#   [#<Todo description: "todo2", position: 1, ...>,
#    #<Todo description: "todo1", position: 2, ...>,
#    #<Todo description: "todo3", position: 3, ...>]>

# todo3のpositionが3になり、他の2つが1,2になっています。
# これも自動的にacts_as_listにより次のようなUPDATE文が走っているため、いい感じになっています。
# UPDATE "todos" SET position = (position - 1) WHERE ("todos"."todo_list_id" = 1 AND position > 1 AND position <= 3)

少しわかりづらいですが、acts_as_listにより自動的にpositionがいい感じに更新されるということです。
今回はこのぐらいしかメソッドを使わないので上記が理解できれば問題ありません。
他にもacts_as_listにはメソッドがたくさん定義されているので気になる方は、acts_as_list - GitHubを参照してください。


では、TodosControllerのStrongParmetersでtarget_positionを追加します。

# app/controllers/api/todos_controller.rb

def todo_params
  params.require(:todo).permit(:description, :completed, :target_position)
end


最後にjbuilderでAnguarJSに返しているJSONの内容にpositionも返すように追加します。

# app/views/api/todo_lists/show.json.jbuilder
  json.id    @todo_list.id
  json.name  @todo_list.name
  json.todos @todo_list.todos.page(1) do |todo|
    json.id          todo.id
    json.description todo.description
    json.completed   todo.completed
+   json.position    todo.position
  end
  json.totalTodos  @todo_list.todos.count


# app/views/api/todo_lists/show.json.jbuilder
  json.name @todo_list.name
  json.todos @todos do |todo|
    json.id          todo.id
    json.description todo.description
    json.completed   todo.completed
+   json.position    todo.position
  end
  json.totalTodos  @total_todos

これで、クライアント側も、サーバー側も一通り実装ができましたので、動作確認をしましょう。
Todoのpositionの値がすべて0だと思いますので、一度bin/rake db:migrate:resetなどしてDBを綺麗な状態にしてから実施してください。

Todoをソートした後に、画面をリロードしてもソート順が保持されているのでうまくいっています。


うまく動かない場合は、Javascriptコンソールを開き、AngularJS側でエラーが出ていないこと、サーバーのupdateメソッドにbyebugを記載し、paramsでtarget_positionが取得できて、Todoの更新に成功していることといった処理の流れを追いながら順に確認してみてください。




5. AngularJS側で各Todoのポジションを更新する

見た目上はうまく更新できてましたが、実は問題があります。
画面にAngularJSのモデルのpositionの値を表示させてみましょう。

<!-- app/views/templates/todo_list.html.erb -->

  <div class="todo-buttons pull-right">
+   ({{ todo.position }})
    <button ...>


新規にTodoを追加したり、ソートさせたり、削除させたりすると、positionの値(右側の()の内容)が正しくないこと見てわかると思います。
f:id:nipe880324:20150124181724j:plain:w480



これは、サーバー側でacts_as_listpositionの値をよしなに更新してますが、それをAngularJS側に返していないためです。
毎回サーバーからデータを返すと画面がちらつき動作ももっさりしてしまい、せっかくのAngularJSの良さが失われてしまうので、AngularJS側でもTodoの追加、ソート、削除のときに適切にpositionを更新するようにします。


まずは、Todoの追加をやります。
作成前に全てのTodoのpositionを+1します。そして、作成したTodoは一番上に追加されるので、positionを1にセットします。(リストはpositionの昇順にならんでいる)

# app/assets/javascripts/controllers/TodoListCtrl.coffee

$scope.addTodo = (todoDescription) ->
  raisePositions($scope.list)
  todo = @todoService.create(description: todoDescription, completed: false)
  todo.position = 1
  $scope.list.todos.unshift(todo)
  $scope.todoDescription = ""


raisePositions = (list) ->
  angular.forEach list.todos, (todo) ->
    todo.position += 1

動作確認すると、todoを追加した場合でも、positionの値がうまく更新されているがわかると思います。


次に、Todo削除をやります。
削除前に、削除するTodoより下にあるTodosのpositionを-1します。

# app/assets/javascripts/controllers/TodoListCtrl.coffee

$scope.deleteTodo = (todo) ->
  lowerPositionsBelow($scope.list, todo)
  @todoService.delete(todo)
  $scope.list.todos.splice($scope.list.todos.indexOf(todo), 1)

# list内の指定したtodoより下のtodosのpositionを-1する
lowerPositionsBelow = (list, todo) ->
  angular.forEach todosBelow(list, todo), (todo) ->
    todo.position -= 1

# list内の指定したtodoより下にあるtodosを取得する
todosBelow = (list, todo) ->
  list.todos.slice(list.todos.indexOf(todo), list.todos.length)


動作確認すると、削除した場合でも、positionの値がうまく更新されていると思います。



最後にソートを行います。
AngularJSのリスト(ng-repeat)は内部的にindexという値を持っているのでその値をソート後に使います。
具体的には、ソート後に全てのTodoのpositionにAnguarJSのアイテムのindex値+1(0から始まるので+1する)を設定します。

# app/assets/javascripts/controllers/TodoListCtrl.coffee

$scope.positionChanged = (todo) ->
  @todoService.update(todo, target_position: todo.position)
  updatePositions($scope.list)

updatePositions = (list) ->
  angular.forEach list.todos, (todo, index) ->
    todo.position = index + 1

動作確認すると、ソートした場合でも、positionの値がうまく更新されていると思います。


では、position確認用の次の記述を削除します。

<!-- app/views/templates/todo_list.html.erb -->

- ({{ todo.position }})


以上です。

これで、この連載もおわりです。
長々とここまでお付き合いくださりありがとうございました。

これで、あなたは、RailsとAngularJSを使ってそこそこのアプリケーションを作れるようになったと思います。
あとは、公式ドキュメントやリファレンス本などで適宜知識を補っていけば良いのではないかと思います。