1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[rails6] 動的なカテゴリー機能の実装 前半(ancestry)

Posted at

#はじめに
ancestryというgemを使用するにあたって、カテゴリー機能を実装するのにとても時間がかかってしまった。備忘録として残しつつ理解を深めていく。

長くなってしまったので前後半で分けた。今回の実装する部分は下記の通り。

  • 前半の実装
  • 初期データの投入(カテゴリーデータの作成)
  • カテゴリーに紐づく動的なセレクトボックスの実装

SNS風の投稿アプリがある想定で下記の箇所を実装していく。
投稿の一覧(index)
投稿機能(new) ←今回の実装部分
投稿の詳細(show)
投稿の編集(edit)

#完成図
スクリーンレコーディング2021-10-08at 10.10.56 AM

##環境
Ruby 2.7.4
Rails 6.1.4
jQuery 4.4.0
ancestry 4.1.0

##テーブル

- postsテーブル
FK category_id
- categoriesテーブル
- id
- name
- ancestry
二つのテーブルは1(posts)対多(categories)の関係。
postは既存のテーブルを想定、後からcategoryを追加する形を取る。
実装にあたり余計なカラムは記述していない。

#カテゴリー登録機能を実装

##JQueryの導入
Rails6でのjQuery導入方法を参照。

ここでjQeryを扱う理由は以下の通り。

ブラウザの違いを意識せずに済む
HTMLのDOM操作が簡単にできる
Ajax処理が簡単に記述できる

参考もと
jQueryとは?/筆者:寺谷文宏(てらたにふみひろ)

##gemの導入

gem 'ancestry'

上記をgemfileに記述。
ターミナルからbundle installを叩いてgemをインストール。

##モデルの準備

rails g model category

Categoryモデルを作成。
Postモデルも同様にここで作成してもいいがカテゴリー機能を後付けで実装する想定で進めるため、Postモデルは既にある状態とする。

##マイグレーションファイルの編集

db/migrate/xxxxxxxxxxxxxx_create_categories.rb
  def change
    create_table :categories do |t|
      t.string :name,     null: false
      t.string :ancestry
      t.timestamps
    end
    add_index :categories, :ancestry
  end

categoriesテーブルにname,ancestryのカラムを記述。

rails g migration AddCategoryIdToPosts 

既存のPostモデルに後付けでカラムを追加するため、新たにマイグレーションファイルを追加して対応。
マイグレーションファイルは一度マイグレーションで反映させてしまうと次回以降、参照対象から外れる。
そのため既存のxxxxxxxxxxx_create_teble.rbは書き換えても意味がない。

db/migrate/xxxxxxxxxxxxx_add_category_id_to_posts.rb
class AddUserIdToPosts < ActiveRecord::Migration[5.2]
  def change
    add_reference :posts, :category, foreign_key: true
  end
end

記述が終わったら忘れずrails db:migrateでマイグレーションする。

##関連付け

app/models/category.rb
class Category < ApplicationRecord
  has_many :posts
  has_ancestry
end

categoryモデルに関連付けを行う。
has_manyで、Categoryは複数のpostを所属することを記述。
has_ancestryで、ancestryの階層化を有効にするよう記述。

app/models/posts.rb
class Post < ApplicationRecord
  belongs_to :category
end

Postモデルに関連付けを行う。
belongs_toで、postがcategoryに従属している事を記述。

##初期データの作成
スクリーンショット 2021-10-01 14.37.38.png
googleスプレットシートを使用。
Aの列にid、Bの列にname、Cの列にancestryを配置する形で情報を記述。

親カテゴリーC列はnillとして扱うため記述をしなくてOK
子カテゴリーC列では、どの親カテゴリーに属しているかを親idで記述。
孫カテゴリーC列では、どの親と子に属しているかを親id/子idで記述。

##初期データをCSVファイルに変換・保存
スクリーンショット 2021-10-01 15.08.57.png
次にgoogleスプレッドシートの項目[ファイル]→[ダウンロード]→[カンマ区切りの値(.csv、現在のシート)]を選択。
シートに記述したデータをcsvファイルに変換してデスクトップに保存。

#CSVファイルを移動
mv ~/Downloads/category.csv ~/sample_app/db/category.csv

保存したCSVファイルをプロジェクトのdbに移す。
出力したCSVファイルはgoogleスプレッドシートのタイトル名+シート名でファイル名が決まるため必要に応じて変換すること。

##初期データの投入

db/seeds.rb
#CSVファイルの読み込みを有効化
require "csv"

##CSVファイルをforeachでインポート
CSV.foreach('db/category.csv') do |row|
  Category.create(:id => row[0], :name => row[1], :ancestry => row[2])
end 

db/seed.rbに上記を記述。
CSV.foreach('csvファイルへのパス')でcsvファイルを指定して読み込む
Category.create(:カラム => ブロック変数[n])でカンマ区切りの値をインデックス番号で振り分けながらカテゴリーを生成する。

リファレンスマニュアル

rails db:seed

ターミナルからrails db:seedを叩いて投入完了。

##ルーティング

config/routes.rb
  resources :posts do
    collection do
      get "get_category_children", defaults: { format: "json" }
      get "get_category_grandchildren", defaults: { format: "json" }
    end
  end

resourcesでpostsに基づく7つの基本的なアクションをルーティング。
collectionで独自に定義するアクションをpostsの配下でルーティング。
生成されるパスはGET posts/get_category_childrenのような感じ。

defaults: { format: "json" }でJSON形式の応答を指定。

##コントローラー

app/controllers/posts_controller.rb
before_action :set_parents

private

  def set_parents
    @set_parents = Category.where(ancestry: nil)
  end

  def post_params
    params.require(:post).permit(:category_id)
  end

set_parentsは親カテゴリーのみを抽出したインスタンス変数を定義。
params.require(:キーに紐づくモデル).permint(:取得するカラム)で意図しないカラムの更新を防ぐためにストロングパラメーターを設定。
privateは以下に定義したアクションを外部から呼び出されないように記述。
before_actionでアクションの実行前に特定のアクションが実行されるように記述

##独自アクションを定義

app/controllers/posts_controller.rb
  def get_category_children
    @category_children = Category.find(params[:parent_id].to_s).children
  end

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

private

ルーティングで設定したJSON用アクションを定義。
get_category_childrenは子カテゴリーのidをJSON経由で取得。
get_category_grandchildrenは孫カテゴリーidをJSON経由で取得。
params[:xxxx_id].to_sは後ほど記述するjavascriptファイル内のajaxから送られる値を記述。
childrenはancestryの提供するインスタンスメソッド。子のレコードを取得する。

##jbuilderファイルを作成

touch app/views/posts/get_category_children.json.jbuilder
touch app/views/posts/get_category_grandchildren.json.jbuilder

2つのjson.jbuilderを作成。
このファイルを介してアクションで定義した値がJSON形式へと変換される。

app/views/posts/get_category_children.json.jbuilder
json.array! @category_children do |child|
  json.id child.id
  json.name child.name
end
app/views/posts/get_category_grandchildren.json.jbuilder
json.array! @category_grandchildren do |grandchild|
  json.id grandchild.id
  json.name grandchild.name
end

下記は変換されたJSONの返り値。
# [{"id": "idの値1", "name": "nameの値1"}, {"id": "idの値2", "name": "nameの値2"}]

##javascriptファイルを作成

touch app/javascript/packs/category_post.js

空のファイルを作って中身を書いていく。
ここの記述によって選択するセレクトボックスがカテゴリーの階層に合わせて動的に変化する。

app/javascript/packs/category_post.js
$(document).on('turbolinks:load', function() {
    //①セレクトボックスに必要なoptionタグを生成
    function appendOption(category){
    var html = `<option value="${category.id}">${category.name}</option>`;
    return html;
  }
  //②子カテゴリー用のセレクトボックスを生成
  function appendChildrenBox(insertHTML){
    var childSelectHtml = "";
    childSelectHtml = `<div class="category__child" id="children_wrapper">
                        <select id="child__category" name="post[category_id]" class="select_field">
                          <option value="">---</option>
                          //optionタグを埋め込む
                          ${insertHTML}
                        </select>
                      </div>`;
    //ブラウザに非同期で子セレクトボックスを表示される
    $('.append__category').append(childSelectHtml);
  }
  //③孫カテゴリー用のセレクトボックスを生成
  function appendGrandchildrenBox(insertHTML){
    var grandchildSelectHtml = "";
    grandchildSelectHtml = `<div class="category__child" id="grandchildren_wrapper">
                              <select id="grandchild__category" name="post[category_id]" class="select_field">
                                <option value="">---</option>
                                //optionタグを埋め込む
                                ${insertHTML}
                                </select>
                            </div>`;
    //ブラウザに非同期で孫セレクトボックスを表示される
    $('.append__category').append(grandchildSelectHtml);
  }
  //親カテゴリー選択時に子カテゴリーのイベント発火
  $('#post_category_id').on('change',function(){
    //選択したカテゴリーのidを取得
    var parentId = document.getElementById('post_category_id').value;
    if (parentId != ""){
      //取得したidをコントローラへ渡し、子カテゴリーを取得
      $.ajax({
        url: '/posts/get_category_children/',
        type: 'GET',
        data: { parent_id: parentId },
        dataType: 'json'
      })
      //親が変更された時に子孫を削除
      .done(function(children){
        $('#children_wrapper').remove();
        $('#grandchildren_wrapper').remove();
        var insertHTML = '';
        //forEachで子カテゴリーを展開
        children.forEach(function(child){
          //①と紐づいて取得した子のid,nameをoptionタグに埋め込む
          insertHTML += appendOption(child);
        });
        appendChildrenBox(insertHTML);
        if (insertHTML == "") {
          $('#children_wrapper').remove();
        }
      })
      //ajax通信の失敗時にアラートを表示
      .fail(function(){
        alert('カテゴリー取得に失敗しました');
      })
    }else{
      $('#children_wrapper').remove();
      $('#grandchildren_wrapper').remove();
    }
  });
  //子カテゴリーの選択時に孫カテゴリーのイベント発火
  $('.append__category').on('change','#child__category',function(){
    var childId = document.getElementById('child__category').value;
    if(childId != ""){
      $.ajax({
        url: '/posts/get_category_grandchildren/',
        type: 'GET',
        data: { child_id: childId },
        dataType: 'json'
      })
      //子が変更された時に孫を削除
      .done(function(grandchildren){
        $('#grandchildren_wrapper').remove();
        var insertHTML = '';
        grandchildren.forEach(function(grandchild){
          insertHTML += appendOption(grandchild);
        });
        appendGrandchildrenBox(insertHTML);
        if (insertHTML == "") {
          $('#grandchildren_wrapper').remove();
        }
      })
      .fail(function(){
        alert('カテゴリー取得に失敗しました');
      })
    }else{
      $('#grandchildren_wrapper').remove();
    }
  })
});

document.getElementById('post_category_id').value;では、セレクトボックスで選択されたidを取得。
$.ajax({~})の部分からJSON用のルートを通ってコントローラーへと取得したidが非同期で渡り、子・孫カテゴリーのレコードを取得。
removeでセレクトボックスが再選択された場合にルートの子・孫を削除。
forEachでajaxで取得した子・孫カテゴリーのレコードを展開。
appendOptionは取得した子・孫レコードからoptionタグにidとnameを埋め込む。
insertHTMLはappendxxxxxxBoxと紐づいて、appendOptionの内容を渡す。
alertはajax通信で失敗した場合にアラートを表示。

##view

app/views/posts/new.html.erb
<%= form_with model: @post, local: true do |f| %>

<div class="append__category">
  <div class="category">
    <div class="form__label">
      <div class="weight-bold-text lavel__name ">
        カテゴリー
      </div>
      <div class="lavel__Required">
        <%= f.collection_select :category_id, @set_parents, :id, :name,{ include_blank: "選択してください"},class:"select_field", id:"post_category_id" %>
      </div>
    </div>
  </div>
</div>

 <%= f.submit class: "btn btn-primary btn-block" %>
<% end %>

collection_selectでモデルの情報をもとにセレクトボックスを作成。
@set_parentsで親カテゴリーを取得。
id:"post_category_idで選択されたカテゴリーidをjavascriptと共有。

#javascriptの読み込みを有効化

app/views/layouts/application.html.erb
  <head>
    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>

javascript_pack_tag 'application'がデフォルト記述されている事を確認。
ここのヘッダー部分からjavascriptの読み込みが行われている。

app/javascript/packs/application.js
require('./category_post');

新たに作成したcategory_post.jsは指定してあげないと読み込まれていないので、上記を記述する事で読み込むようにする。

ひとまず、ここまでがカテゴリーの登録機能の実装は完了。
次の記事でカテゴリーに所属するpostを検索・表示する機能を追加する。

#参考もと
ancestry/公式ドキュメント
[Rails]カテゴリー機能
【Rails】 gem ancestry カテゴリー機能実装について
ancestryによる多階層構造データを用いて、動的カテゴリーセレクトボックスを実現する~Ajax~

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?