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

Rails Webook

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

Railsでawesome_nested_setとjsTreeでインタラクティブにツリー構造を操作する

Rails中級 Rails Model

f:id:nipe880324:20150215174000j:plain:w480

インタラクティブに階層構造を操作できるjQuery pluginのjsTreeを使い、上記のようにUIを向上させます。


Railsでawesome_nested_setを使って階層構造を作成する」の実施を前提にしています。

動作確認

  • Ruby 2.1.2
  • Rails 4.2.0
  • awesome_nested_set 3.0.2
  • jsTree 3.0.9

目次

2. jsTreeの各アクションとRailsのAPIを連携させる

2.1. カテゴリの初期データをサーバーから取得する
2.2. カテゴリの追加/リネーム/削除アクションを実装する
2.3. カテゴリの移動アクションを実装する




1. jsTreeでクライアント側の動きを実装する

1では、「カテゴリの操作(追加、リネーム、削除、移動)」をできるようにします。サーバーと連携(カテゴリデータの永続化)は2で実施します。


1.1. RailsにjsTreeをインストールする

まずは、RailsにjsTreeをインストールします。

1. 「Download jsTree」からソースファイルをダウンロードします。

2. 解凍したファイルのdist/jstree.min.jsvendor/assets/javascripts/.にコピーします。

3. 解凍したファイルのdist/themes/default/*vendor/assets/stylesheets/.にコピーします。
そして、style.cssを削除し、style.min.cssjstree.min.cssにリネームします。

4. application.jsjstree.minを追加します。

// app/assets/javascripts/application.js
//= require jstree.min

5. application.cssにもjstree.minを追加します。

// app/assets/stylesheets/application.css
 *= require jstree.min

6. 一覧画面を次のように修正します。テーブルや登録画面へのリンクなどを削除します。

<!-- app/views/categories/index.html.erb -->
<h1>Listing Categories</h1>

<div id="jstree_categories">
  <ul>
    <li data-jstree='{"opened":true}'>Root node 1
      <ul>
        <li>Child node 1</li>
        <li>Child node 2</li>
      </ul>
    </li>
    <li>Root node 2
    </li>
  </ul>
</div>

7. カテゴリーのjstree()を呼びます。

# app/assets/javascripts/categories.coffee
$ ->
  $('#jstree_categories').jstree()

8. おまけでスタイルを追加しておきます。(必須ではありません)

<!-- app/views/layouts/application.html.erb -->
<head>
  ...
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
  ...
</head>
// app/assets/stylesheets/categories.scss

.categories {
  #jstree_categories {
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 3px;
    background-color: #efefef;
  }
}

では、画面を表示してみましょう。
f:id:nipe880324:20150215174032j:plain:w480




1.2. jsTreeのプラグインでカテゴリーを移動できるようにする

jsTreeには、チェックボックス、右クリックを有効にする、検索ボックスを追加するなどさまざまなjsTreeプラグインが用意されています。


今回はドラッグ&ドロップをできるようにするdndプラグインを追加します。

# app/assets/javascripts/categories.coffee
$ ->
  $('#jstree_categories').jstree({
    "core" : {
      "check_callback" : true
    },
    "plugins" : [ "dnd" ]
  })

  # カテゴリを移動させたときに呼ばれるイベント
  $('#jstree_categories').on "move_node.jstree", (e, n) ->
    # Todo サーバーのデータを更新するようにする
    console.log "#{n.old_parent}:#{n.old_position} -> #{n.parent}:#{n.position}"


では、実際にドラッグ&ドロップができるか確認します。
f:id:nipe880324:20150215174050j:plain:w480




1.3. カテゴリーの追加、リネーム、削除を追加する

右クリックを追加するプラグインからもカテゴリの「追加」、「リネーム」、「削除」のUIを追加できるのですが、次のように今回はボタンで操作するようにします。
f:id:nipe880324:20150215174059j:plain:w480


まずはボタンを追加します。

<!-- app/views/categories/index.html.erb -->

<!-- 一番下に追加 -->

<br />

<%= link_to 'Create', "#", class: 'btn btn-success', id: 'create_category' %>
<%= link_to 'Rename', "#", class: 'btn btn-warning', id: 'rename_category' %>
<%= link_to 'Delete', "#", class: 'btn btn-danger',  id: 'delete_category' %>


そして、ボタンを押したときの処理を登録します。
各処理では、カテゴリが選択されていたらボタンに記載されているアクションを実行します。カテゴリが選択されていない場合は何もしないという挙動になっています。

# app/assets/javascripts/categories.coffee
$ ->

  ...

  # 選択されているノードの子として新しいノードを作成する
  $('#create_category').on 'click', ->
    jstree = $('#jstree_categories').jstree(true) # jstreeオブジェクトを取得
    selected = jstree.get_selected()   # 選択されているカテゴリを取得
    return false if (!selected.length) # 選択されていない場合何もしないで終了

    selected = selected[0]  # 複数選択もあるのでselectedは配列なので、0番目を取得
    selected = jstree.create_node(selected, {"type":"directory"}) # create_nodeでノードを作成
    jstree.edit(selected) if (selected) # 作成したノードを編集状態にする


  # 選択されているノードの名前を変更する
  $('#rename_category').on 'click', ->
    jstree = $('#jstree_categories').jstree(true)
    selected = jstree.get_selected()
    return false if (!selected.length)

    selected = selected[0]
    jstree.edit(selected);


  # ノードの名前の変更が確定されたときに呼ばれるイベント
  $('#jstree_categories').on 'rename_node.jstree', (e, n) ->
    # TODO サーバーのノードの名前を更新する
    console.log "#{n.node.id}: #{n.old} -> #{n.text}"


  # 選択されているノードを削除する
  $('#delete_category').on 'click', ->
    jstree = $('#jstree_categories').jstree(true)
    selected = jstree.get_selected()
    return false if (!selected.length)

    jstree.delete_node(selected); # delete_nodeでノードを削除する


では、画面を確認して、ローカル上でカテゴリの追加、リネーム、削除ができることを確認しましょう。
f:id:nipe880324:20150215174119j:plain:w480




2. jsTreeの各アクションとRailsのAPIを連携させる

1では、サーバーと連携しないでカテゴリの操作(追加、リネーム、削除、移動)をできるようにしました。
2では、サーバーと連携し、カテゴリデータを永続化できるようにしていきます。
scaffold機能で作成された機能でほぼRails側のAPIはできているので、jsでAjaxリクエストを送る箇所が主になります。


2.1. カテゴリの初期データをサーバーから取得する

まずは、実際にサーバーと通信する前に、前準備として、カテゴリの階層構造を初期データを、HTMLからJSONに変更します。


まずはHTMLを削除します。

<!-- app/views/categories/index.html.erb -->
<h1>Listing Categories</h1>

<div id="jstree_categories"></div>

<br />

<%= link_to 'Create', "#", class: 'btn btn-success', id: 'create_category' %>
<%= link_to 'Rename', "#", class: 'btn btn-warning', id: 'rename_category' %>
<%= link_to 'Delete', "#", class: 'btn btn-danger',  id: 'delete_category' %>


そして、HTMLの代わりにJSONとしてjstreeメソッドに渡します。

# app/assets/javascripts/categories.coffee
$ ->
  $('#jstree_categories').jstree({
    'core' : {
      'check_callback' : true,
      'data' : [ # 画面に表示する仮の初期データ
        { 'id' : '1', 'parent' : '#', 'text' : 'Root node 1', 'state' : { 'opened' : true } },
        { 'id' : '2', 'parent' : '1', 'text' : 'Child node 1' },
        { 'id' : '3', 'parent' : '1', 'text' : 'Child node 2' },
        { 'id' : '4', 'parent' : '#', 'text' : 'Root node 2' }
      ]
    },
    "plugins" : [ "dnd" ]
  })

  ...

フォーマットには何種類かあり詳細はjsTree - jsonフォーマットを参照してください。


画面が変わりなく表示されることを確認します。
f:id:nipe880324:20150215174059j:plain:w480


では、Ajaxでサーバー側からカテゴリの一覧を取得するようにします。
core.data.urlにカテゴリの一覧へのURL(categories.json)を記載すると画面表示時に、GET /categories.jsonをアクセスし、サーバーからカテゴリ一覧情報を取得するようになります。

# app/assets/javascripts/categories.coffee
$ ->
  $('#jstree_categories').jstree({
    'core' : {
      'check_callback' : true,
      'data' : {
        'url' : (node) ->
          return 'categories.json' # GET /categoris.json を実行する
      }
    },
    "plugins" : [ "dnd" ]
  })

  ...


では、Railsからカテゴリ一覧を返すようにします。
返すデータ形式は、先ほどまで配列でcore.dateに指定していた値です。

# app/views/categories/index.json.jbuilder
json.array!(@categories) do |category|
  json.id     category.id.to_s
  json.text   category.name
  json.parent category.parent_id ? category.parent_id.to_s : '#'
  json.state do
    json.opened true
  end
end

※ルートノードの親ノードは、awesome_nested_setの場合は「nil」、jsTreeでは「#」になるので、json.parent category.parent_id ? category.parent_id.to_s : '#'としてその変換を行っています。


では、サーバー側に仮の初期データを入力します

# もしデータが残っている場合は、次のコマンドでDBのデータをリセットしてください
$ bin/rake db:migrate:reset

# コンソールからデータを初期データを入力します
$ bin/rails c
root = Category.create(name: 'Root node 1')
root.children.append(Category.create(name: 'Child node 1'))
root.children.append(Category.create(name: 'Child node 2'))
Category.create(name: 'Root node 2')


では、画面を表示しましょう。正しくカテゴリ一覧のデータが表示されると思います。
Ajaxでサーバーから取得しているので、一瞬だけロード中のイメージが表示されるようになます。
f:id:nipe880324:20150215174059j:plain:w480



2.2. カテゴリの追加/リネーム/削除アクションを実装する

カテゴリの一覧ができたので、カテゴリの追加/リネーム/削除も作っていきます。
追加/リネーム/削除はScaffold機能で既に実装されているので、javascriptしか修正しません。

# app/assets/javascripts/categories.coffee
$ ->
  ...

  # 選択されているノードの子として新しいノードを作成する
  $('#create_category').on 'click', ->
    jstree = $('#jstree_categories').jstree(true)
    selected = jstree.get_selected()
    return false if (!selected.length)
    selected = selected[0]

    # POST /categories.json
    $.ajax({
      'type'    : 'POST',
      'data'    : { 'category' : { 'name' : 'New node', 'parent_id' : selected } },
      'url'     : '/categories.json',
      'success' : (res) ->
        selected = jstree.create_node(selected, res)
        jstree.edit(selected) if (selected)
    })


  # 選択されているノードの名前を変更する
  $('#rename_category').on 'click', ->
    jstree = $('#jstree_categories').jstree(true)
    selected = jstree.get_selected()
    return false if (!selected.length)

    selected = selected[0]
    jstree.edit(selected);


  # ノードの名前の変更が確定されたときに呼ばれるイベント
  $('#jstree_categories').on 'rename_node.jstree', (e, obj) ->
    id           = obj.node.id
    renamed_name = obj.text

    # PATCH /categories/id.json
    $.ajax({
      'type'    : 'PATCH',
      'data'    : { 'category' : { 'name' : renamed_name } },
      'url'     : "/categories/#{id}.json"
    })


  # 選択されているノードを削除する
  $('#delete_category').on 'click', ->
    jstree = $('#jstree_categories').jstree(true)
    selected = jstree.get_selected()
    return false if (!selected.length)

    selected = selected[0]
    id = selected

    # DELETE /categories/id.json
    $.ajax({
      'type'    : 'DELETE',
      'url'     : "/categories/#{id}.json",
      'success' : ->
        jstree.delete_node(selected)
    })


では、画面を更新して、動作を確認します。カテゴリの作成、リネーム、削除ができ、画面を更新しても変更が保存されているはずです。
f:id:nipe880324:20150215174151j:plain:w480




2.3. カテゴリの移動アクションを実装する

では、最後に、ドラッグ&ドロップでカテゴリを移動できるようにします。

まずは、javascriptでカテゴリを移動させたら、サーバー側にリクエストを送ります。
移動先を正しく更新するために、「移動後の親ノードのID」と「移動後のポジション番号」をサーバーに送ります。

# app/assets/javascripts/categories.coffee
$ ->
  ...

  # カテゴリを移動させたときに呼ばれるイベント
  $('#jstree_categories').on "move_node.jstree", (e, node) ->
    id            = node.node.id
    parent_id     = node.parent
    new_position  = node.position

    # PATCH /categories/id.json
    $.ajax({
      'type'    : 'PATCH',
      'data'    : { 'category' : { 'parent_id' : parent_id, 'new_position' : new_position } },
      'url'     : "/categories/#{id}.json"
    })


CategoriesコントローラーのStrongParametersにnew_positionを追加します。

# app/controllers/categories_controller.rb
def category_params
  params.require(:category).permit(:name, :parent_id, :new_position)
end


最後に、Categoryモデルに2つのメソッドを追加します。

# app/models/category.rb
class Category < ActiveRecord::Base
  acts_as_nested_set

  # ルートノードに移動させた場合は、parent_id は "#"になるので、
  # ルートに移動させる move_to_root メソッドを呼ぶ
  # 親ノードがある場合は、parent_id を 更新する
  def parent_id=(parent_id)
    if parent_id == '#'
      move_to_root
    else
      super(parent_id)
    end
  end

  # ルートノードに移動させた場合は、親ノード(parent)がnilのため、ルートの兄弟配列から移動先を特定する
  # 親ノードがある場合は、move_to_child_with_indexメソッドで移動する
  def new_position=(new_position)
    if parent.blank?
      prev_node = root.siblings[new_position.to_i - 1]
      move_to_right_of prev_node
    else
      move_to_child_with_index(parent, new_position.to_i)
    end
  end
end


では、カテゴリの移動ができることを確認します。
f:id:nipe880324:20150215174000j:plain:w480


以上です。

参考文献