1
1

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-11-13

目標

ezgif.com-video-to-gif.gif

実装

1.Gemを導入

Gemfile
# 追記
gem 'ancestry'
ターミナル
$ bundle

2.カラムを追加

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

ターミナル
$ rails g migration AddAncestryToCategory ancestry:string:index
ターミナル
$ rails db:migrate
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.モデルを編集

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つですが、親ではない

階層構造

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

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

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

【解説】

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

category_parent_array = ['---']

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

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

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

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

return category_parent_array

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

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つ受け取る。

(book, parent_id, children_id, grandchildren_id)

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

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

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

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

category = Category.find(parent_id)

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

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

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

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つ渡して実行する。

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

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

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

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

@category_parent_array = Category.category_parent_array_create

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

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

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

ターミナル
$ touch app/views/books/get_category_children.json.jbuilder
books/get_category_children.json.jbuilder
json.array! @category_children do |children|
  json.id children.id
  json.name children.name
end
ターミナル
$ touch app/views/books/get_category_grandchildren.json.jbuilder
books/get_category_grandchildren.json.jbuilder
json.array! @category_grandchildren do |grandchildren|
  json.id grandchildren.id
  json.name grandchildren.name
end

【解説】

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

json.array! @category_children do |children|

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

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.ルーティングを追加

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.ビューを編集

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に設定する。

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

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

ターミナル
$ touch app/assets/javascripts/category_form.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();
    }
  });
});

【解説】

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

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

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

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

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

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

${category.name}

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

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);
}

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

name='[children_id]

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

${insertHTML}

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

$('.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();
  }
});

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

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

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

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

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

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

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

.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();
  }
});

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

if (grandchildren.length != 0)

完成!!!

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1