【Rails】ancestryを用いた多階層カテゴリー機能の実装『作成フォーム編』

# 目標

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

# 開発環境
・Ruby: 2.5.7
・Rails: 5.2.4
・Vagrant: 2.2.7
・VirtualBox: 6.1
・OS: macOS Catalina

# 前提

下記実装済み。

・[Slim導入](https://qiita.com/matsubishi5/items/b639ffddecc626856039)
・[Bootstrap3導入](https://qiita.com/matsubishi5/items/b117c95f4dd71340cc80)
・[Font Awesome導入](https://qiita.com/matsubishi5/items/a4c72f436e64ad106ee8)
・[ログイン機能実装](https://qiita.com/matsubishi5/items/5bd8fdd45af955cf137d)
・[投稿機能実装](https://qiita.com/matsubishi5/items/1a5aeab651b5822d1f2c)
・[多対多のカテゴリー機能実装](https://qiita.com/matsubishi5/items/a805fbcfd96f768dd622)
・[多階層カテゴリー機能実装(準備編)](https://qiita.com/matsubishi5/items/77aadc97a2e63c98cd91)
・[多階層カテゴリー機能実装(seed編)](https://qiita.com/matsubishi5/items/91248fce37b4b0d2e0f1)

# 実装

## 1.`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
```

## 2.`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)
```

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

```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
```

## 4.`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": "税金"
  },
]
```

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

```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' }
```

## 6.ビューを編集

```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'
```

## 7.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)
```

# 注意

`turbolinks`を無効化しないとセレクトボックスが非同期で動作しないので、必ず無効化しておきましょう。

[turbolinksを無効化する方法](https://qiita.com/matsubishi5/items/c4c8a5df03ae630ae534)

# 続編

[多階層カテゴリー機能実装(編集フォーム編)](https://qiita.com/matsubishi5/items/10e61f314f6c56b8690d)