#はじめに
ancestry
というgemを使用するにあたって、カテゴリー機能を実装するのにとても時間がかかってしまった。備忘録として残しつつ理解を深めていく。
長くなってしまったので前後半で分けた。今回の実装する部分は下記の通り。
- 前半の実装
- 初期データの投入(カテゴリーデータの作成)
- カテゴリーに紐づく動的なセレクトボックスの実装
SNS風の投稿アプリがある想定で下記の箇所を実装していく。
投稿の一覧(index)
投稿機能(new) ←今回の実装部分
投稿の詳細(show)
投稿の編集(edit)
##環境
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モデルは既にある状態とする。
##マイグレーションファイルの編集
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
は書き換えても意味がない。
class AddUserIdToPosts < ActiveRecord::Migration[5.2]
def change
add_reference :posts, :category, foreign_key: true
end
end
記述が終わったら忘れずrails db:migrate
でマイグレーションする。
##関連付け
class Category < ApplicationRecord
has_many :posts
has_ancestry
end
categoryモデルに関連付けを行う。
has_many
で、Categoryは複数のpostを所属することを記述。
has_ancestry
で、ancestryの階層化を有効にするよう記述。
class Post < ApplicationRecord
belongs_to :category
end
Postモデルに関連付けを行う。
belongs_to
で、postがcategoryに従属している事を記述。
##初期データの作成
googleスプレットシートを使用。
Aの列にid
、Bの列にname
、Cの列にancestry
を配置する形で情報を記述。
親カテゴリーC列はnill
として扱うため記述をしなくてOK
子カテゴリーC列では、どの親カテゴリーに属しているかを親id
で記述。
孫カテゴリーC列では、どの親と子に属しているかを親id/子id
で記述。
##初期データをCSVファイルに変換・保存
次にgoogleスプレッドシートの項目[ファイル]→[ダウンロード]→[カンマ区切りの値(.csv、現在のシート)]を選択。
シートに記述したデータをcsv
ファイルに変換してデスクトップに保存。
#CSVファイルを移動
mv ~/Downloads/category.csv ~/sample_app/db/category.csv
保存したCSVファイルをプロジェクトのdb
に移す。
出力したCSVファイルはgoogleスプレッドシートのタイトル名+シート名でファイル名が決まるため必要に応じて変換すること。
##初期データの投入
#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
を叩いて投入完了。
##ルーティング
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形式の応答を指定。
##コントローラー
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
でアクションの実行前に特定のアクションが実行されるように記述
##独自アクションを定義
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形式へと変換される。
json.array! @category_children do |child|
json.id child.id
json.name child.name
end
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
空のファイルを作って中身を書いていく。
ここの記述によって選択するセレクトボックスがカテゴリーの階層に合わせて動的に変化する。
$(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
<%= 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の読み込みを有効化
<head>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>
javascript_pack_tag 'application'
がデフォルト記述されている事を確認。
ここのヘッダー部分からjavascriptの読み込みが行われている。
require('./category_post');
新たに作成したcategory_post.js
は指定してあげないと読み込まれていないので、上記を記述する事で読み込むようにする。
ひとまず、ここまでがカテゴリーの登録機能の実装は完了。
次の記事でカテゴリーに所属するpostを検索・表示する機能を追加する。
#参考もと
ancestry/公式ドキュメント
[Rails]カテゴリー機能
【Rails】 gem ancestry カテゴリー機能実装について
ancestryによる多階層構造データを用いて、動的カテゴリーセレクトボックスを実現する~Ajax~