記事概要
Railsで階層構造があるカテゴリーを作成する方法をまとめる。
前提
- Ruby on Railsでアプリケーションを作成している
- ライブラリ「pry-rails」をインストール済みである
サンプルアプリ(GitHub)
階層構造のデータベースについて
1つのデータが複数のデータに対して、「親子関係」を持っている
イメージ
各カテゴリーは複数存在するので、3世代それぞれの関係性は全て「多対多」になる
テーブル設計
テーブル同士の関係性が「多対多」のときは、「中間テーブル」を作成するが、テーブル設計が非常に複雑になる
ancestry
階層構造の編成を可能にするGem
ancestryを導入することにより「親・子・孫の関係」を簡単に紐付けることができる
手順1(ancestryを導入する)
- Gem
ancestry
を追加する
詳細は、こちらを参照
手順2(モデルを生成する)
- Categoryモデルを作成する
% rails g model category
- マイグレーションファイルを編集する
db/migrate/20**********_create_categories.rb
class CreateCategories < ActiveRecord::Migration[7.1] def change create_table :categories do |t| t.string :name t.string :ancestry t.timestamps end end end
- マイグレーションファイルを実行する
% rails db:migrate
- Categoryモデルに
ancestry
を追加するため、category.rbを編集する
has_ancestry
を記述することにより、モデルが階層構造になるapp/models/category.rbclass Category < ApplicationRecord has_ancestry end
手順3(データベースに値を保存する)
- シードファイル
db/seeds.rb
を更新する- シードファイルに記述されている内容を削除する
- ActiveRecordのcreateメソッドを使用し、categoriesテーブルに親要素を追加する
db/seeds.rb
# 親カテゴリー ladies,mens,baby = Category.create([{name: "レディース"}, {name: "メンズ"},{name: "ベビー・キッズ"}])
- ancestryを導入しているので、createメソッドで生成したオブジェクトに対して、childrenメソッドが使用できる
変数.children
と記述することで、子要素として扱えるdb/seeds.rb# 親カテゴリー ladies,mens,baby = Category.create([{name: "レディース"}, {name: "メンズ"},{name: "ベビー・キッズ"}]) # 子カテゴリー tops_ladies,bottoms_ladies = ladies.children.create([{name: "トップス"},{name: "ボトムス"}]) tops_mens,bottoms_mens = mens.children.create([{name: "トップス"},{name: "ボトムス"}]) kidsw_baby,omutu_baby,toy_baby = baby.children.create([{name: "キッズウェア"},{name: "おむつ/トイレ/バス"},{name: "おもちゃ"}])
- childrenメソッドを使用し、孫要素を追加する
db/seeds.rb
# 親カテゴリー ladies,mens,baby = Category.create([{name: "レディース"}, {name: "メンズ"},{name: "ベビー・キッズ"}]) # 子カテゴリー tops_ladies,bottoms_ladies = ladies.children.create([{name: "トップス"},{name: "ボトムス"}]) tops_mens,bottoms_mens = mens.children.create([{name: "トップス"},{name: "ボトムス"}]) kidsw_baby,omutu_baby,toy_baby = baby.children.create([{name: "キッズウェア"},{name: "おむつ/トイレ/バス"},{name: "おもちゃ"}]) # 孫カテゴリー ## レディース tops_ladies.children.create([{name: "Tシャツ/カットソー(半袖/袖なし)"}, {name: "Tシャツ/カットソー(七分/長袖)"},{name: "シャツ/ブラウス(半袖/袖なし)"},{name: "その他"}]) bottoms_ladies.children.create([{name: "デニム/ジーンズ"},{name: "ショートパンツ"},{name: "カジュアルパンツ"},{name: "キュロット"},{name: "スカート"},{name: "その他"}]) ## メンズ tops_mens.children.create([{name: "Tシャツ/カットソー(半袖/袖なし)"}, {name: "Tシャツ/カットソー(七分/長袖)"},{name: "シャツ/ブラウス(半袖/袖なし)"},{name: "その他"}]) bottoms_mens.children.create([{name: "デニム/ジーンズ"},{name: "ショートパンツ"},{name: "カジュアルパンツ"},{name: "キュロット"},{name: "スカート"},{name: "その他"}]) ## ベビー・キッズ ["コート","トップス","スカート","パンツ","ワンピース","セットアップ"].each do |name| kidsw_baby.children.create(name: name) end ["おむつ用品","おまる/補助便座","トレーニングパンツ","ベビーバス","お風呂用品","その他"].each do |name| omutu_baby.children.create(name: name) end ["おふろのおもちゃ","がらがら","知育玩具","その他"].each do |name| toy_baby.children.create(name: name) end
- categoriesテーブルにシード値を追加する
% rails db:seed
-
Sequel Pro
を開き、レコードが追加されていることを確認する
id name ancestry 備考 1 レディース NULL 4 トップス 1 親カテゴリーのid 13 シャツ/ブラウス(半袖/袖なし) 1/4 親カテゴリーと子カテゴリーのid
手順4(親カテゴリーを表示する)
- コントローラーとビューを作成する
% rails g controller categories new
- ルーティングを設定する
config/routes.rb
Rails.application.routes.draw do root 'categories#new' resources :categories, only:[:new] end
-
categries_controller.rb
を編集するapp/controllers/categries_controller.rbclass CategoriesController < ApplicationController def new @categories = Category.new # 親カテゴリーが3つなので、limitは3とし、昇順に並び替え @maincategories = Category.all.order("id ASC").limit(3) end end
-
app/views/categories/new.html.erb
を編集し、初期の親カテゴリーのidを選択できるように記述する<%= form_with model: @categories, url: '/categories/new' do |f|%> <div> カテゴリーの選択 <div id="select-wrap"> <%#= セレクトボックスの選択肢は、初期の親カテゴリーのidを選択できるように記述 %> <%= f.collection_select :ancestry, @maincategories, :id, :name, {include_blank: "---"}, {id: "parent-category" } %> </div> </div> <% end %>
- ブラウザにて、親カテゴリが選択できることを確認する
手順5(子カテゴリーを表示する)
- jsファイルを作成する
-
app/javascript/category.js
を手動作成する - importmapを編集する
config/importmap.rb
# 最終行に追記 pin "category", to: "category.js"
- application.jsを編集する
app/javascript/application.js
// 最終行に追記 import "category"
- jsファイルが読み込まれるように記述する
category.js
function category (){ console.log("category.jsファイルの読み込み完了") }; window.addEventListener('turbo:load', category); window.addEventListener("turbo:render", category);
-
- jsファイルが読み込まれたことを、ブラウザにて確認する
- 子カテゴリーが表示される土台を作る
category.js
function category (){ // セレクトボックスの選択肢を取得する const parentCategory = document.getElementById('parent-category') // selectChildElementという関数を定義 const selectChildElement = (selectForm) => { } // parentCategoryという要素の値を選択が変更された時に、changeイベント発火 parentCategory.addEventListener('change', function () { // (selectForm)へ要素が格納される selectChildElement('child-select-wrap') console.log('add child category') }) }; window.addEventListener('turbo:load', category); window.addEventListener("turbo:render", category);
- ブラウザにて、親カテゴリをクリックした際にイベント発火していることを確認する
- ルーティングを設定する
config/routes.rb
# 省略 get '/category/:id', to: 'categories#search' end
- 上記のように記述することで、
http://localhost:3000/category/1
のようなURLを生成できる - URLにidを付与することで、paramsにidを含め、コントローラーでidの取得が可能になる
- 上記のように記述することで、
- 子カテゴリーの値全て取得するための関数を記述する
category.js
// 省略 // selectChildElementという関数を定義 const selectChildElement = (selectForm) => { } // 子カテゴリーの値を全て取得する関数を定義 const getChildCategoryData = () => { } // parentCategoryという要素の値を選択が変更された時に、changeイベント発火 parentCategory.addEventListener('change', function () { // 省略
- 親カテゴリーの値を取得するように記述する
category.js
// 省略 // 子カテゴリーの値を全て取得する関数を定義 const getChildCategoryData = () => { // 親カテゴリーの値を取得する const parentValue = parentCategory.value } // 省略
- 非同期通信でリクエストを送信する
category.js
// 省略 // selectChildElementという関数を定義 const selectChildElement = (selectForm) => { } // JSON形式でレスポンスを設定し、コントローラーにリクエストを送信 const XHR = new XMLHttpRequest(); const categoryXHR = (id) => { // /category/:idというエンドポイントへのリクエストを記述 XHR.open("GET", `/category/${id}`, true); XHR.responseType = "json"; XHR.send(); } // 子カテゴリーの値を全て取得する関数を定義 const getChildCategoryData = () => { // 親カテゴリーの値を取得する const parentValue = parentCategory.value // 子カテゴリーの値を取得するためには親カテゴリーと紐付ける必要があるため、親カテゴリーの値を引数とする categoryXHR(parentValue) } // parentCategoryという要素の値を選択が変更された時に、changeイベント発火 parentCategory.addEventListener('change', function () { // (selectForm)へ要素が格納される selectChildElement('child-select-wrap') // getChildCategoryData()の処理を呼び出す getChildCategoryData() }) // 省略
- searchアクションを定義し、レスポンスを返す
app/controller/categories_controller.rb
# 省略 def search # idをもとに、該当する親カテゴリーのレコードを取得しitemに代入 item = Category.find(params[:id]) # 子カテゴリーの要素を取得し、children_itemに代入 children_item = item.children # JSON形式でレスポンスを返す render json:{ item: children_item } end end
- 値が送られているかを確認する
- 値が送られているか確認するため、
binding.pry
を追記するapp/controller/categories_controller.rbdef search # idをもとに、該当する親カテゴリーのレコードを取得しitemに代入 item = Category.find(params[:id]) # 子カテゴリーの要素を取得し、children_itemに代入 children_item = item.children binding.pry # JSON形式でレスポンスを返す render json:{ item: children_item } end end
- ブラウザで親カテゴリーを選択する
- ターミナルを開き、変数
item
children_item
に正しく値を取得できているか確認
-
binding.pry
を削除する
- 値が送られているか確認するため、
- レスポンス後の処理を実行する
category.js
// 省略 // 子カテゴリーの値を全て取得する関数を定義 const getChildCategoryData = () => { // 親カテゴリーの値を取得する const parentValue = parentCategory.value // 子カテゴリーの値を取得するためには親カテゴリーと紐付ける必要があるため、親カテゴリーの値を引数とする categoryXHR(parentValue) // コントローラーからのレスポンスの受信に成功した場合の処理 XHR.onload = () => { // searchアクションから返却したitemは、「XHR.response.item」で取得できる const items = XHR.response.item; } } // 省略
- 子カテゴリーのプルダウンを表示する
category.js
function category (){ // セレクトボックスの選択肢を取得する const parentCategory = document.getElementById('parent-category') // 選択フォーム画面を取得 const selectWrap = document.getElementById('select-wrap') // 中略 const appendChildSelect = (items) => { // div要素とselect要素を生成する const childWrap = document.createElement('div') const childSelect = document.createElement('select') // 「div要素」に、('id', 'child-select-wrap')を追加 childWrap.setAttribute('id', 'child-select-wrap') // 「select要素」に('id', 'child-select')を追加 childSelect.setAttribute('id', 'child-select') // forEach文でitems(コントローラーから返却された、子カテゴリーの値)を繰り返し表示 items.forEach(item => { const childOption = document.createElement('option') childOption.innerHTML = item.name childOption.setAttribute('value', item.id) childSelect.appendChild(childOption) }); // childSelectの中に、子要素として追加 childWrap.appendChild(childSelect) // childWrapの中に、子要素として追加 selectWrap.appendChild(childWrap) } // parentCategoryという要素の値を選択が変更された時に、changeイベント発火 parentCategory.addEventListener('change', function () { // (selectForm)へ要素が格納される selectChildElement('child-select-wrap') // getChildCategoryData()の処理を呼び出す getChildCategoryData() }) }; window.addEventListener('turbo:load', category); window.addEventListener("turbo:render", category);
- 下記のような子カテゴリーの選択フォームを表示することができる
<div id = "child-select-wrap"> <select id = "child-select"> <option value="item.id">item.name</option> <option value="item.id">item.name</option> <option value="item.id">item.name</option> <option value="item.id">item.name</option> <option value="item.id">item.name</option> <option value="item.id">item.name</option>
- 子カテゴリーの値を取得する
category.js
// 省略 // コントローラーからのレスポンスの受信に成功した場合の処理 XHR.onload = () => { // searchアクションから返却したitemは、「XHR.response.item」で取得できる const items = XHR.response.item; // appendChildSelectで定義した処理を実行し、子カテゴリーの選択フォームを取得する appendChildSelect(items) const childCategory = document.getElementById('child-select') } // 省略
- ブラウザにて、子カテゴリーが選択できることを確認する
手順6(孫カテゴリーを表示する)
- 子カテゴリーのプルダウンの値が変化することによって、孫カテゴリーの選択フォームが表示されるイベントを定義する
category.js
// 省略 // コントローラーからのレスポンスの受信に成功した場合の処理 XHR.onload = () => { // searchアクションから返却したitemは、「XHR.response.item」で取得できる const items = XHR.response.item; // appendChildSelectで定義した処理を実行し、子カテゴリーの選択フォームを取得する appendChildSelect(items) const childCategory = document.getElementById('child-select') // 子カテゴリーのプルダウンの値が変化することによって、孫カテゴリーの選択フォームが表示されるイベントを定義 childCategory.addEventListener('change', () => { selectChildElement('grand-child-select-wrap') getGrandchildCategoryData(childCategory) }) } // 省略
- 孫カテゴリーの値を取得する関数を定義する
category.js
// 省略 // 孫カテゴリーの値を取得する関数を定義 const getGrandchildCategoryData = (grandchildCategory) => { const grandchildValue = grandchildCategory.value categoryXHR(grandchildValue) XHR.onload = () => { const GrandChildItems = XHR.response.item; appendGrandChildSelect(GrandChildItems) } } // parentCategoryという要素の値を選択が変更された時に、changeイベント発火 parentCategory.addEventListener('change', function () { // (selectForm)へ要素が格納される selectChildElement('child-select-wrap') // getChildCategoryData()の処理を呼び出す getChildCategoryData() }) }; window.addEventListener('turbo:load', category); window.addEventListener("turbo:render", category);
- 孫カテゴリーのプルダウンを表示させる
category.js
// 省略 // 孫カテゴリーのプルダウンを表示させる const appendGrandChildSelect = (items) => { const childWrap = document.getElementById('child-select-wrap') const grandchildWrap = document.createElement('div') const grandchildSelect = document.createElement('select') grandchildWrap.setAttribute('id', 'grand-child-select-wrap') grandchildSelect.setAttribute('id', 'grand-child-select') items.forEach(item => { const grandchildOption = document.createElement('option') grandchildOption.innerHTML = item.name grandchildOption.setAttribute('value', item.id) grandchildSelect.appendChild(grandchildOption) }); grandchildWrap.appendChild(grandchildSelect) childWrap.appendChild(grandchildWrap) } // parentCategoryという要素の値を選択が変更された時に、changeイベント発火 parentCategory.addEventListener('change', function () { // (selectForm)へ要素が格納される selectChildElement('child-select-wrap') // getChildCategoryData()の処理を呼び出す getChildCategoryData() }) }; window.addEventListener('turbo:load', category); window.addEventListener("turbo:render", category);
- ブラウザにて、孫カテゴリーが選択できることを確認する
手順7(選択フォームを繰り返し表示する)
- 現状、再度ジャンルの違うカテゴリーを選択しようとすると、選択フォームが下に連なってしまう
- 孫カテゴリーまで選択した時に、再度違うジャンルのカテゴリーが選択できるように記述する
category.js
// 省略 // selectChildElementという関数を定義 const selectChildElement = (selectForm) => { // 再度違うジャンルのカテゴリーが選択できるようにする if (document.getElementById(selectForm) !== null) { document.getElementById(selectForm).remove() } } // 省略
- ブラウザを確認する