Railsで多階層カテゴリーを実装してみた

# 目標
![ezgif.com-video-to-gif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/579893/823563e9-302b-6a20-b712-0cb207d73472.gif)

# 実装
### 1.Gemを導入

```ruby:Gemfile
# 追記
gem 'ancestry'
```

```terminal:ターミナル
$ bundle
```

### 2.カラムを追加

データ量が多くなる可能性があるので念の為`index`を張っておきます。

```terminal:ターミナル
$ rails g migration AddAncestryToCategory ancestry:string:index
```

```terminal:ターミナル
$ rails db:migrate
```

```ruby:schema.rb
create_table "categories", force: :cascade do |t|
  t.string "name"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.string "ancestry"
  t.index ["ancestry"], name: "index_categories_on_ancestry"
end
```

### 3.モデルを編集

```ruby:category.rb
# 追記
has_ancestry
```

`has_ancestry`
➡︎ このクラスでancestryを使える様になる。

# メソッド一覧

|メソッド名|返り値|
|---|---|
|parent|親レコードを取得|
|parent_id|親レコードのIDを取得|
|root|レコードのルートを取得|
|root_id|レコードのルートIDを取得|
|root?is_root?|レコードがルートであれば、trueを返す|
|ancestors|ルートで始まり、親で終わる、レコードの祖先を返す|
|ancestors?|レコードに祖先（ルートノードではない）がある場合はtrueを返す|
|ancestor_ids|レコードの祖先のIDを返す|
|path|ルートで始まり、自己で終わる、レコードのパスを返す|
|path_ids|ルートIDで始まり、 自己のIDで終わるパスのIDをリストで返す|
|children|子レコードを取得|
|child_ids|子レコードのIDを取得|
|has_parent?ancestors?|レコードが親を持っていれば、trueを返す|
|has_children?children?|レコードが子を持っていれば、trueを返す|
|is_childless?childless?|レコードが子を持っていなければ、trueを返す|
|siblings|兄弟レコード（同じ階層のレコード）を返す|
|sibling_ids|兄弟レコード（同じ階層のレコード）のIDを返す|
|has_siblings?siblings?|レコードの親に複数の子がある場合はtrueを返す|
|is_only_child?only_child?|レコードが親の唯一の子である場合はtrueを返す|
|descendants|子レコード、孫レコード、曽孫レコード... を返す|
|descendant_ids|子レコード、孫レコード、曽孫レコード... のIDを返す|
|indirects|孫レコード以下を返す|
|indirect_ids|孫レコード以下のIDを返す|
|subtree|子孫と自己のモデルを返す|
|subtree_ids|レコードのサブツリーのIDをリストで返す|
|depth|ノードの深さを返す|
|parent_of?(node)|このレコードを(node)の親にする|
|root_of?(node)|このレコードを(node)のルートにする|
|ancestor_of?(node)|(node)の祖先にはこのレコードが含まれる|
|child_of?(node)|(node)はレコードの親|
|descendant_of?(node)|(node)はこのレコードの祖先の1つ|
|indirect_of?(node)|(node)はこのレコードの祖先の1つですが、親ではない|

# 階層構造

**親：ビジネス**
**子：経済**
**孫：日本経済、国際経済**

本のカテゴリーに上記の様な親子関係を持たせたい場合は、以下の様にデータを作成します。

```ruby:
business = Category.create(name: 'ビジネス')

business_economy = business.children.create(name: '経済')

business_economy.children.create([{ name: '日本経済' }, { name: '国際経済' }])
```

### カラム構造
|id|name|ancestry|
|---|---|---|
|1|ビジネス|nil|
|2|経済|1(親のid)|
|3|日本経済|1/2(親のid/子のid)|
|4|国際経済|1/2(親のid/子のid)|

## 2.`category.rb`にメソッドを作成

```category.rb
def self.category_parent_array_create
  category_parent_array = ['---']
  Category.where(ancestry: nil).each do |parent|
    category_parent_array << [parent.name, parent.id]
  end
  return category_parent_array
end
```

### 【解説】

#### ① 親カテゴリーのセレクトボックスを作成する為の配列を用意し、初期値を格納する。

```ruby
category_parent_array = ['---']
```

#### ② ancestryの値が`nil`、つまり親カテゴリーを全て抽出し、①で作成した配列にカテゴリー名とIDを格納する。

```ruby
Category.where(ancestry: nil).each do |parent|
  category_parent_array << [parent.name, parent.id]
end
```

**※重要ポイント**
`[parent.name, parent.id]`
➡︎ ビューで親カテゴリーをセレクトボックスに表示する際、
第１引数(parent.name)がブラウザに表示される値になり、
第２引数(parent.id)がパラメーターとして送信する値になる。

#### ③ `①` で作成した配列を戻り値として返す。

```ruby
return category_parent_array
```

## 3.`book_category.rb`にメソッドを作成

```ruby:book_category.rb
  def self.maltilevel_category_create(book, parent_id, children_id, grandchildren_id)
    if parent_id.present? && parent_id != '---'
      category = Category.find(parent_id)
      BookCategory.create(book_id: book.id, category_id: category.id)
    end

    if children_id.present? && children_id != '---'
      category = Category.find(children_id)
      BookCategory.create(book_id: book.id, category_id: category.id)
    end

    if grandchildren_id.present? && grandchildren_id != '---'
      category = Category.find(grandchildren_id)
      BookCategory.create(book_id: book.id, category_id: category.id)
    end
  end
```

### 【解説】

#### ① コントローラーから引数を4つ受け取る。

```ruby
(book, parent_id, children_id, grandchildren_id)
```
`book` : 作成する本のデータ
`parent_id` : 親カテゴリーのID
`children_id` : 子カテゴリーのID
`grandchildren_id` : 孫カテゴリーのID

#### ② 親カテゴリーのIDを引数として受け取れた場合かつ、初期値でない場合処理を実行する。

```ruby
if parent_id.present? && parent_id != '---'
```

#### ③ Categoryモデルから引数で受け取った親カテゴリーIDに対応するレコードを抽出し、変数に代入する。

```ruby
category = Category.find(parent_id)
```

#### ④ BookCategory(中間テーブル)にレコードを作成する。**

```ruby
BookCategory.create(book_id: book.id, category_id: category.id)
```

## 4.コントローラーを編集

```ruby:books_controller.rb
def create
  @book = Book.new(book_params)
  @book.user_id = current_user.id
  if @book.save
    BookCategory.maltilevel_category_create(
      @book,
      params[:parent_id],
      params[:children_id],
      params[:grandchildren_id]
    )
    redirect_to books_path
  else
    @books = Book.all
    @category_parent_array = Category.category_parent_array_create
    render 'index'
  end
end

def index
  @book = Book.new
  @books = Book.all
  @category_parent_array = Category.category_parent_array_create
end

def get_category_children
  @category_children = Category.find(params[:parent_id]).children
end

def get_category_grandchildren
  @category_grandchildren = Category.find(params[:children_id]).children
end
```

### 【解説】

#### ① `maltilevel_category_create`メソッドに引数を4つ渡して実行する。


```ruby
BookCategory.maltilevel_category_create(
  @book,
  params[:parent_id],
  params[:children_id],
  params[:grandchildren_id]
)
```

**◎ Ajax通信で送られてきたパラメーターを受け取る。**

```ruby
params[:parent_id],
params[:children_id],
params[:grandchildren_id]
```

#### ② `category_parent_array_create`メソッドの戻り値として受け取った配列をインスタンス変数に代入する。

```ruby
@category_parent_array = Category.category_parent_array_create
```

#### ③ 選択された親カテゴリーに紐付く子カテゴリーを抽出する。

```ruby
def get_category_children
  @category_children = Category.find(params[:parent_id]).children
end
```

#### ④ 選択された子カテゴリーに紐付く孫カテゴリーを抽出する。

```ruby
def get_category_grandchildren
  @category_grandchildren = Category.find(params[:children_id]).children
end
```

## 5.`json.jbuilderファイル`を作成・編集

```terminal:ターミナル
$ touch app/views/books/get_category_children.json.jbuilder
```

```ruby:books/get_category_children.json.jbuilder
json.array! @category_children do |children|
  json.id children.id
  json.name children.name
end
```

```terminal:ターミナル
$ touch app/views/books/get_category_grandchildren.json.jbuilder
```

```ruby:books/get_category_grandchildren.json.jbuilder
json.array! @category_grandchildren do |grandchildren|
  json.id grandchildren.id
  json.name grandchildren.name
end
```

### 【解説】

#### ① `get_category_children`アクションで抽出したレコードを繰り返し処理し、配列を作成する。

```ruby
json.array! @category_children do |children|
```

#### ② 各IDと名前を `①` で作成した配列に格納する。

```ruby
json.id children.id
json.name children.name
```

**◎ 親カテゴリー(ビジネス)が選択された場合の返り値**

```
[
  {
    "id": 2, 
    "name": "金融"
  },
  {
    "id": 6, 
    "name": "経済"
  },
  {
    "id": 9, 
    "name": "経営"
  },
  {
    "id": 13, 
    "name": "マーケティング"
  },
]
```

**◎ 子カテゴリー(金融)が選択された場合**

```
[
  {
    "id": 3, 
    "name": "株"
  },
  {
    "id": 4, 
    "name": "為替"
  },
  {
    "id": 5, 
    "name": "税金"
  },
]
```

## 6.ルーティングを追加

```ruby:routes.rb
# 追記
get 'get_category/children', to: 'books#get_category_children', defaults: { format: 'json' }
get 'get_category/grandchildren', to: 'books#get_category_grandchildren', defaults: { format: 'json' }
```

## 7.ビューを編集

```slim:books/index.html.slim
.category-form
  = label_tag 'ジャンル'
  = select_tag 'parent_id', options_for_select(@category_parent_array), id: 'parent-category', class: 'form-control'
  i.fas.fa-chevron-down
br
```

### 【解説】

#### ① コントローラーで定義したインスタンス変数内のデータをセレクトボックスに表示し、プロパティ(パラメーター名)を`parent_id`に設定する。

```slim
= select_tag 'parent_id', options_for_select(@category_parent_array), id: 'parent-category', class: 'form-control'
```

## 8.JavaScriptファイルを作成・編集

```terminal:ターミナル
$ touch app/assets/javascripts/category_form.js
```

```js:category_form.js
$(function() {
  function appendOption(category) {
    let html = `<option value='${category.id}' data-category='${category.id}'>${category.name}</option>`;
    return html;
  }

  function appendChidrenBox(insertHTML) {
    let childrenSelectHtml = '';
    childrenSelectHtml = `
      <div id='children-wrapper'>
        <select id='children-category' class='form-control' name='[children_id]'>
          <option value='---' data-category='---'>---</option>
          ${insertHTML}
        </select>
        <i class='fas fa-chevron-down'></i>
      </div>
    `;
    $('.category-form').append(childrenSelectHtml);
  }

  function appendGrandchidrenBox(insertHTML) {
    let grandchildrenSelectHtml = '';
    grandchildrenSelectHtml = `
      <div id='grandchildren-wrapper'>
        <select id='grandchildren-category' class='form-control' name='[grandchildren_id]'>
          <option value='---' data-category='---'>---</option>
          ${insertHTML}
        </select>
        <i class='fas fa-chevron-down'></i>
      </div>
    `;
    $('.category-form').append(grandchildrenSelectHtml);
  }

  $('#parent-category').on('change', function() {
    let parentId = document.getElementById('parent-category').value;
    if (parentId != '---') {
      $.ajax({
        url: '/get_category/children',
        type: 'GET',
        data: {
          parent_id: parentId,
        },
        dataType: 'json',
      })
        .done(function(children) {
          $('#children-wrapper').remove();
          $('#grandchildren-wrapper').remove();
          let insertHTML = '';
          children.forEach(function(children) {
            insertHTML += appendOption(children);
          });
          appendChidrenBox(insertHTML);
        })
        .fail(function() {
          alert('ジャンル取得に失敗しました');
        });
    } else {
      $('#children-wrapper').remove();
      $('#grandchildren-wrapper').remove();
    }
  });

  $('.category-form').on('change', '#children-category', function() {
    let childrenId = $('#children-category option:selected').data('category');
    if (childrenId != '---') {
      $.ajax({
        url: '/get_category/grandchildren',
        type: 'GET',
        data: {
          children_id: childrenId,
        },
        dataType: 'json',
      })
        .done(function(grandchildren) {
          if (grandchildren.length != 0) {
            $('#grandchildren-wrapper').remove();
            let insertHTML = '';
            grandchildren.forEach(function(grandchildren) {
              insertHTML += appendOption(grandchildren);
            });
            appendGrandchidrenBox(insertHTML);
          }
        })
        .fail(function() {
          alert('ジャンル取得に失敗しました');
        });
    } else {
      $('#grandchildren-wrapper').remove();
    }
  });
});
```

### 【解説】

#### ① セレクトボックスのオプションを設定する。

```js
$(function() {
  function appendOption(category) {
    let html = `<option value='${category.id}' data-category='${category.id}'>${category.name}</option>`;
    return html;
  }
```

**◎ パラメーターとして送信する値を設定する。**

```js
<option value='${category.id}' data-category='${category.id}'>
```

**※重要ポイント**
`3.コントローラーを編集`の `①` で受け取るパラメーターに該当する。

**◎ セレクトボックスに表示する値を設定する。**

```js
${category.name}
```

#### ② 子ジャンルのセレクトボックスを作成する。

```js
function appendChidrenBox(insertHTML) {
  let childrenSelectHtml = '';
  childrenSelectHtml = `
    <div id='children-wrapper'>
      <select id='children-category' class='form-control' name='[children_id]'>
        <option value='---' data-category='---'>---</option>
        ${insertHTML}
      </select>
      <i class='fas fa-chevron-down'></i>
    </div>
  `;
  $('.category-form').append(childrenSelectHtml);
}
```

**◎ `①` で作成したパラメーターの、`パラメーター名`を設定する。**

```js
name='[children_id]
```

**◎ `①` で設定したオプションを元に、子カテゴリーのセレクトボックスを作成する。**

```js
${insertHTML}
```

**◎ 子カテゴリーのセレクトボックスを表示する。**

```js
$('.category-form').append(childrenSelectHtml);
```

#### ③ 孫ジャンルのセレクトボックスを作成する。( `②` とほぼ同じなので説明は省略)

```js
function appendGrandchidrenBox(insertHTML) {
  let grandchildrenSelectHtml = '';
  grandchildrenSelectHtml = `
    <div id='grandchildren-wrapper'>
      <select id='grandchildren-category' class='form-control' name='[grandchildren_id]'>
        <option value='---' data-category='---'>---</option>
        ${insertHTML}
      </select>
      <i class='fas fa-chevron-down'></i>
    </div>
  `;
  $('.category-form').append(grandchildrenSelectHtml);
}
```

#### ④ 親カテゴリーが選択された時に発火するイベントを作成する。

```js
$('#parent-category').on('change', function() {
  let parentId = document.getElementById('parent-category').value;
  if (parentId != '---') {
    $.ajax({
      url: '/get_category/children',
      type: 'GET',
      data: {
        parent_id: parentId,
      },
      dataType: 'json',
    })
      .done(function(children) {
        $('#children-wrapper').remove();
        $('#grandchildren-wrapper').remove();
        let insertHTML = '';
        children.forEach(function(children) {
          insertHTML += appendOption(children);
        });
        appendChidrenBox(insertHTML);
      })
      .fail(function() {
        alert('ジャンル取得に失敗しました');
      });
  } else {
    $('#children-wrapper').remove();
    $('#grandchildren-wrapper').remove();
  }
});
```

**◎ 親カテゴリーが選択された時に発火する。**

```js
$('#parent-category').on('change', function() {});
```

**◎ 選択された親カテゴリーのIDを取得し、変数に代入する。**

```js
let parentId = document.getElementById('parent-category').value;
```

**◎ 親カテゴリーが初期値でない場合、
パラメーター(parent_id)に先ほど取得した親カテゴリーのIDを設定して、
`get_category_children`アクションを非同期で実行する。**

```js
if (parentId != '---') {
  $.ajax({
    url: '/get_category/children',
    type: 'GET',
    data: {
      parent_id: parentId,
    },
    dataType: 'json',
  })
```

**◎ Ajax通信が成功した場合は、子カテゴリーのセレクトボックスを作成する。
また、既に子カテゴリー以下のセレクトボックスが表示されている状態で親カテゴリーが変更された場合は、
子カテゴリー以下のセレクトボックスを削除し、子カテゴリーのセレクトボックスを作成し直す。**

```js
.done(function(children) {
  $('#children-wrapper').remove();
  $('#grandchildren-wrapper').remove();
  let insertHTML = '';
  children.forEach(function(children) {
    insertHTML += appendOption(children);
  });
  appendChidrenBox(insertHTML);
})
```

**◎ 非同期通信に失敗した場合は、アラートを表示する。**

```js
.fail(function() {
  alert('ジャンル取得に失敗しました');
});
```

**◎ 親カテゴリーが初期値の場合は、子カテゴリー以下を削除する。**

```js
} else {
  $('#children-wrapper').remove();
  $('#grandchildren-wrapper').remove();
}
```

#### ④ 子カテゴリーが選択された時に発火するイベントを作成する。( `③` とほぼ同じ)

```js
$('.category-form').on('change', '#children-category', function() {
  let childrenId = $('#children-category option:selected').data('category');
  if (childrenId != '---') {
    $.ajax({
      url: '/get_category/grandchildren',
      type: 'GET',
      data: {
        children_id: childrenId,
      },
      dataType: 'json',
    })
      .done(function(grandchildren) {
        if (grandchildren.length != 0) {
          $('#grandchildren-wrapper').remove();
          let insertHTML = '';
          grandchildren.forEach(function(grandchildren) {
            insertHTML += appendOption(grandchildren);
          });
          appendGrandchidrenBox(insertHTML);
        }
      })
      .fail(function() {
        alert('ジャンル取得に失敗しました');
      });
  } else {
    $('#grandchildren-wrapper').remove();
  }
});
```

**◎ 孫カテゴリーが無いカテゴリーもあるので条件を付与する。(必要に応じて子カテゴリーにも条件を付与する)**

```js
if (grandchildren.length != 0)
```

# 完成！！！
ancestory便利ですね！
気合いコード感が半端ないので、気になるところあればコメントでご指摘頂ければと思います。