【Rails】階層構造のデータ作成

# 記事概要
Railsで階層構造があるデータを保存する方法をまとめる。

# 言語やフレームワーク
|| 使用技術 |
| ---- | ---- |
| フロントエンド | HTML |
| バックエンド | Ruby 3.2.0 <br> Ruby on Rails 7.0.8.6 |
| データベース | MySQL |
| インフラ | - |
| API | - |
| その他 | ancestry(Gem) |

# 前提
- Gemの[ancestry](https://github.com/stefankroes/ancestry)を適用している

# サンプルアプリ(GitHub)
https://github.com/likapantrip/category-app.git

# 処理画面
## 階層構造があるデータ(categories)
[![Image from Gyazo](https://i.gyazo.com/ce978dffc8392faa2255c0a9f74ddd95.jpg)](https://gyazo.com/ce978dffc8392faa2255c0a9f74ddd95)

## トップページ
[![Image from Gyazo](https://i.gyazo.com/cac752eba0cc8ded0c6c7321f802d361.png)](https://gyazo.com/cac752eba0cc8ded0c6c7321f802d361)

## データ保存
[![Image from Gyazo](https://i.gyazo.com/848c59b7b679f737f8a8a45b4cfda909.gif)](https://gyazo.com/848c59b7b679f737f8a8a45b4cfda909)

## データ編集
[![Image from Gyazo](https://i.gyazo.com/5c50bfaf766e3d94ee452b0b3df77dfe.gif)](https://gyazo.com/5c50bfaf766e3d94ee452b0b3df77dfe)

# 手順(モデルの作成＋設定)
## categories
モデルを作成するため、ターミナルで下記を実行する
```
% rails g model category
```

`app/models/category.rb`を下記のように編集する
```category.rb
class Category < ApplicationRecord
  validates :name, presence: true

  has_many :items
  has_ancestry
end
```

## items
モデルを作成するため、ターミナルで下記を実行する
```
% rails g model item
```

`app/models/item.rb`を下記のように編集する
```item.rb
class Item < ApplicationRecord
  validates :name         , presence: true
  
  # 孫カテゴリのみを保存対象とするため、id=11~46を保存可能とする
  validates :category_id  , presence: true, format: { with: /\A1[1-9]|[2-3][0-9]|4[0-6]+\z/, message: "can't save value" }
  
  belongs_to :category
end
```

# テーブルの編集
## categories
`db/migrate/yyyymmddhhmmss_create_categories.rb`を下記のように編集する
```yyyymmddhhmmss_create_categories.rb
class CreateCategories < ActiveRecord::Migration[7.0]
  def change
    create_table :categories do |t|
      t.string :name, null: false
      t.string :ancestry
      t.timestamps
    end
  end
end
```

## items
`db/migrate/yyyymmddhhmmss_create_items.rb`を下記のように編集する
```yyyymmddhhmmss_create_items.rb
class CreateItems < ActiveRecord::Migration[7.0]
  def change
    create_table :items do |t|
      t.string     :name      , null: false
      t.references :category  , null: false, foreign_key: true
      t.timestamps
    end
  end
end
```

# シード値の作成
`db/seeds.rb`を下記のように編集する
```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
```

シード値を作成するため、ターミナルで下記を実行する
```
% rails db:seed
```

# 手順(ルーティングの設定)
`config/routes.rb`にルーティングを設定する
```routes.rb
Rails.application.routes.draw do
  root 'items#index'
  resources :items, only:[:index, :new, :create, :edit, :update] do
    get 'search'
  end
end
```

# 手順(コントローラーの作成＋設定)
コントローラーを作成するため、ターミナルで下記を実行する
```
% rails g controller items
```

`app/controllers/items_controller.rb`を下記のように編集する
```items_controller.rb
class ItemsController < ApplicationController
  def index
    @items = Item.all
  end

  def new
    @item = Item.new
    set_categories
  end

  def create
    set_categories
    @item = Item.new(set_params)
    if @item.save
      redirect_to root_path
    else
      render :new, status: :unprocessable_entity
    end
  end

  def edit
    get_item
    set_categories
  end

  def update
    get_item
    set_categories
    if @item.update(set_params)
      redirect_to root_path
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def search
    item = Category.find(params[:item_id])
    children_item = item.children
    render json:{ item: children_item }
  end

  private

  def set_params
    params.require(:item).permit(:name, :category_id)
  end

  def get_item
    @item = Item.find(params[:id])
  end

  def set_categories
    @maincategories = Category.all.order("id ASC").limit(3)
  end
end
```

# 手順(ビューファイルの作成＋設定)
## index
app/views/itemsフォルダに`index.html.erb`を手動作成し、ビューファイルを編集する
```erb
【商品一覧】  <%= link_to '商品登録', new_item_path %></br>
</br>
<% @items.each do |i| %>
  商品名：<%= i.name %>　<%= link_to '編集', edit_item_path(i.id) %></br>
  カテゴリー：<%= i.category.root.name %> > <%= i.category.parent.name %> > <%= i.category.name %></br>
  </br>
<% end %>
```

## new
app/views/itemsフォルダに`new.html.erb`を手動作成し、ビューファイルを編集する
```erb
【商品登録】  <%= link_to '戻る', root_path %></br>
<%= render 'shared/form' %>
```

## edit
app/views/itemsフォルダに`edit.html.erb`を手動作成し、ビューファイルを編集する
```erb
【<%= @item.name %>の商品編集】  <%= link_to '戻る', root_path %></br>
<%= render 'shared/form' %>
```

## 部分テンプレート(form)
app/viewsフォルダに`shared`フォルダを手動作成後、フォルダ内に`_form.html.erb`を手動作成し、ビューファイルを編集する
```erb
<%= form_with(model: @item, local: true) do |f|%>
  <%= render 'shared/error_messages', model: f.object %>

  <div>
    商品名
    <div>
      <%= f.text_field :name %>
    </div>
  </div>
  <div>
    カテゴリーの選択
    <div id="select-wrap">
      <%= f.collection_select :category_id, @maincategories, :id, :name, {include_blank: "---"}, {id: "parent-category" } %>
    </div>
  </div>
  </br>
  <div>
    <%= f.submit '投稿する' %>
  </div>
<% end %>
```

## 部分テンプレート(error_messages)
app/views/sharedフォルダに`_error_messages.html.erb`を手動作成し、ビューファイルを編集する
```erb
<% if model.errors.any? %>
<div class="error-alert">
  <ul>
    <% model.errors.full_messages.each do |message| %>
    <li class='error-message'><%= message %></li>
    <% end %>
  </ul>
</div>
<% end %>
```

# 手順(JavaScriptの作成＋設定)
## jsファイルの設定
app/javascriptフォルダに`category.js`を手動作成し、下記のように編集する
```category.js
function category (){
  console.log("category.jsファイルの読み込み完了")
};

window.addEventListener('turbo:load', category);
window.addEventListener("turbo:render", category);
```

`config/importmap.rb`を下記のように編集する
```importmap.rb
# 中略
pin "category", to: "category.js"
```

`app/javascript/application.js`を下記のように編集する
```application.js
// 中略
import "category"
```

## jsファイルの読み込み
サーバーを起動し、ブラウザのコンソールに「category.jsファイルの読み込み完了」と表示されることを確認する

## jsファイルの編集
`app/javascript/category.js`を下記のように編集する
```category.js
function category (){
  const parentCategory = document.getElementById('parent-category')
  const selectWrap = document.getElementById('select-wrap')
  if (!parentCategory){ return false;}

  // 選択フォームを繰り返し表示する
  const selectChildElement = (selectForm) => {
    if (document.getElementById(selectForm) !== null) {
      document.getElementById(selectForm).remove()
    }
  }

  // Ajax通信を可能にする
  const XHR = new XMLHttpRequest();
  const categoryXHR = (id) => {
    XHR.open("GET", `/items/${id}/search`, true);
    XHR.responseType = "json";
    XHR.send();
  }

  // 子カテゴリーの値を全て取得する関数
  const getChildCategoryData = () => {
    const parentValue = parentCategory.value
    categoryXHR(parentValue)

    // コントローラーからJSON形式でレスポンスの受信が成功した時に、onloadが発火する
    XHR.onload = () => {
      const items = XHR.response.item;
      appendChildSelect(items)
      const childCategory = document.getElementById('child-select')

      // 子カテゴリーのプルダウンの値が変化することによって孫カテゴリーのイベント発火する
      childCategory.addEventListener('change', () => {
        selectChildElement('grand-child-select-wrap')
        getGrandchildCategoryData(childCategory)
      })
    }
  }

  // 子カテゴリーの値を全て取得する関数
  const appendChildSelect = (items) => {

    const childWrap = document.createElement('div')
    const childSelect = document.createElement('select')

    childWrap.setAttribute('id', 'child-select-wrap')
    childSelect.setAttribute('id', 'child-select')

    //子カテゴリーの初期値設定
    const childOption = document.createElement('option')
    childOption.innerHTML = '---'
    childSelect.appendChild(childOption)

    // forEach文でitem（子カテゴリーの値）を繰り返す
    items.forEach(item => {
      const childOption = document.createElement('option')
      childOption.innerHTML = item.name
      childOption.setAttribute('value', item.id)
      childSelect.appendChild(childOption)
    });

    childWrap.appendChild(childSelect)
    selectWrap.appendChild(childWrap)
  }

  // 孫カテゴリーの値を全て取得する関数 
  const getGrandchildCategoryData = (grandchildCategory) => {
    const grandchildValue = grandchildCategory.value
    categoryXHR(grandchildValue)

    // コントローラーからJSON形式でレスポンスの受信が成功した時に、onloadが発火する
    XHR.onload = () => {
      const GrandChildItems = XHR.response.item;
      appendGrandChildSelect(GrandChildItems)
    }
  }

  // 孫カテゴリーのプルダウンを表示させる関数
  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')
    grandchildSelect.setAttribute('name', 'item[category_id]') // category_idの値を上書き

    //孫カテゴリーの初期値設定
    const grandchildOption = document.createElement('option')
    grandchildOption.innerHTML = '---'
    grandchildOption.setAttribute('value', 0)
    grandchildSelect.appendChild(grandchildOption)

    // forEach文でitem（孫カテゴリーの値）を繰り返す
    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.addEventListener('change', function () {
    selectChildElement('child-select-wrap')
    getChildCategoryData()
  })
};

window.addEventListener('turbo:load', category);
window.addEventListener("turbo:render", category);
```

# 備考
- ancestry(Gem)を使用しない場合テーブル設計が複雑になるため、Gemを適用する