記事概要
Ruby on Railsにタグ付け機能を実装する方法について、まとめる
前提
- Ruby on Railsでアプリケーションを作成している
- 投稿機能を実装済みである
サンプルアプリ(GitHub)
タグ付け機能とは
データベース設計
多対多のテーブル設計を行う
テーブル名 | 目的 |
---|---|
postsテーブル | 投稿を保存する |
tagsテーブル | タグを保存する |
post_tag_relationsテーブル | 中間テーブル |
データの保存方法
複数のモデルに同時に保存する必要があるので、Formオブジェクトを利用する
インクリメンタルサーチ
文字入力の都度、自動的に検索が行われる検索機能
逐次検索機能のこと
例)「ruby」というタグがすでにDBに存在するうえで、検索欄に「r」の文字が入力されたとする。このとき、「r」の文字と一致する「ruby」を検索結果の候補として、リアルタイムで画面上に表示する
手順1(Formオブジェクトを用いた投稿機能を実装する)
-
app/models/post_form.rb
を手動作成する - post_form.rbを編集する
app/models/post_form.rb
class PostForm include ActiveModel::Model end
- PostFormクラスを使用してpostsテーブルに保存したいカラム名をすべて指定する
app/models/post_form.rb
class PostForm include ActiveModel::Model #PostFormクラスのオブジェクトがPostモデルの属性を扱えるようにする attr_accessor :text, :image end
- PostFormクラスに、Postモデルのバリデーションを設定する
app/models/post_form.rb
# 省略 #PostFormクラスのオブジェクトがPostモデルの属性を扱えるようにする attr_accessor :text, :image # Postモデルのバリデーション with_options presence: true do validates :text validates :image end # 省略
- Postモデルに記載していたバリデーションを削除する
app/models/post.rb
class Post < ApplicationRecord has_one_attached :image #validates :text, presence: true #validates :image, presence: true end
- PostFormクラスにsaveメソッドを追記する
app/models/post_form.rb
# 省略 # フォームから送られてきた情報をテーブルに保存する処理 def save Post.create(text: text, image: image) end end
- newアクション、createアクションに記述しているインスタンス変数を、PostFormクラスのインスタンス変数
@post_form
に変更するapp/controllers/posts_controller.rb# 省略 def new @post_form = PostForm.new end def create @post_form = PostForm.new(post_params) if @post_form.valid? @post_form.save redirect_to root_path else render :new, status: :unprocessable_entity end end # 省略
- ストロングパラメーターのメソッド名を
post_form_params
、requireメソッドのキーを:post_form
に変更するapp/controllers/posts_controller.rb# 省略 def create @post_form = PostForm.new(post_form_params) if @post_form.valid? @post_form.save redirect_to root_path else render :new, status: :unprocessable_entity end end # 中略 private def post_form_params params.require(:post_form).permit(:text, :image) end # 省略
-
app/views/posts/new.html.erb
を編集し、renderメソッドのlocalsオプションで変数を指定する<h3>新規投稿ページ</h3> <%#= render partial: "form" %> <%= render partial: 'form', locals: {url: posts_path, method: :post} %>
-
app/views/posts/_form.html.erb
を編集し、form_withのmodelオプションに渡すオブジェクトを、@post_form
に変更する<%#= form_with model: @post, id: 'new_post', local: true do |f| %> <%= form_with model: @post_form, url: url, method: method, id: 'new_post', local: true do |f| %> <%#= 省略 %>
- リロードし、新規投稿できることをブラウザで確認する
手順2(Formオブジェクトを用いた編集機能を実装する)
- コントローラーのeditアクションを変更する
app/controllers/posts_controller.rb
# 省略 def edit # @postから情報をハッシュとして取り出す post_attributes = @post.attributes # @post_formとしてインスタンス生成 @post_form = PostForm.new(post_attributes) end # 省略
- コントローラーのupdateアクションを変更する
app/controllers/posts_controller.rb
# 省略 def update # paramsの内容を反映したインスタンスを生成する @post_form = PostForm.new(post_form_params) if @post_form.valid? # バリデーションに問題なければ、値を更新 @post_form.update(post_form_params, @post) redirect_to root_path else render :edit, status: :unprocessable_entity end end # 省略
- データを更新する処理を変更する
app/models/post_form.rb
# 省略 # フォームから送られてきた情報を使用して、レコードを更新する処理 def update(params, post) post.update(params) end end
-
app/views/posts/edit.html.erb
を編集し、renderメソッドのlocalsオプションで変数を指定する<h3>編集ページ</h3> <%#= render partial: 'form' %> <%= render partial: 'form', locals: {url: post_path(@post.id), method: :patch} %>
- ブラウザで編集画面を確認すると、下記エラーが発生している
PostFormクラスでは、attr_accesor
を使ってカラム名を扱えるように設定していたが、idは指定していない。これがエラーの原因 - PostFormクラスに、idなどのカラム名を指定する
app/models/post_form.rb
class PostForm include ActiveModel::Model #PostFormクラスのオブジェクトがPostモデルの属性を扱えるようにする attr_accessor( :text, :image, :id, :created_at, :updated_at ) # 省略
- 投稿日時に関連したカラムである
created_at
updated_at
の値も追加
- 投稿日時に関連したカラムである
- ブラウザにて、確認する
-
@post_form.image
が定義されていない場合、自己代入演算子||=
を使用して@post
のimage情報を代入するapp/controllers/posts_controller.rb# 省略 def update # paramsの内容を反映したインスタンスを生成する @post_form = PostForm.new(post_form_params) # 画像を選択し直していない場合は、既存の画像をセットする @post_form.image ||= @post.image.blob # 省略
- テキスト・画像を修正しない場合でも、正常にデータ保存できることを確認する
手順3(タグを保存するモデルを作成する)
- Tagモデルを作成する
% rails g model tag
- マイグレーションファイルを編集する
db/migrate/20XXXXXXXXXXXX_create_tags.rb
class CreateTags < ActiveRecord::Migration[7.1] def change create_table :tags do |t| t.string :tag_name, null: false # タグの重複を避ける t.timestamps end add_index :tags, :tag_name, unique: true # 検索を簡単にする end end
- 中間テーブルのPostTagモデルを作成する
% rails g model post_tag_relation
- マイグレーションファイルを編集する
db/migrate/20XXXXXXXXXXXX_create_post_tag_relations.rb
class CreatePostTagRelations < ActiveRecord::Migration[7.1] def change create_table :post_tag_relations do |t| t.references :post, foreign_key: true t.references :tag, foreign_key: true t.timestamps end end end
- マイグレートする
% rails db:migrate
- 各モデルのアソシエーションを設定する
- Postモデル
app/models/post.rb
# 省略 has_many :post_tag_relations has_many :tags, through: :post_tag_relations end
- Tagモデル
app/models/tag.rb
class Tag < ApplicationRecord has_many :post_tag_relations has_many :posts, through: :post_tag_relations end
- PostTagRelationモデル
app/models/post_tag_relation.rb
class PostTagRelation < ApplicationRecord belongs_to :post belongs_to :tag end
- Postモデル
- バリデーションを記述する
一意性の制約はモデル単位で設ける必要があるため、FormオブジェクトのPostFormクラスではなく、Tagモデルに記述するapp/models/tag.rbclass Tag < ApplicationRecord has_many :post_tag_relations has_many :posts, through: :post_tag_relations validates :tag_name, uniqueness: true end
手順4(タグを一緒に投稿できるようにする)
- attr_accessorに
tag_name
を追加するapp/models/post_form.rb#PostFormクラスのオブジェクトがPostモデルの属性を扱えるようにする attr_accessor( :text, :image, :id, :created_at, :updated_at, :tag_name )
:updated_at
の後ろの,
を忘れないように注意 -
saveメソッド
の内容を編集し、タグの情報と、タグと記事の紐付けの情報を保存する処理を追加するapp/models/post_form.rb# フォームから送られてきた情報をテーブルに保存する処理 def save #Post.create(text: text, image: image) post = Post.create(text: text, image: image) # 保存したpostのレコードを変数postに代入 if tag_name.present? tag = Tag.where(tag_name: tag_name).first_or_initialize # tagが重複して保存されることを防ぐため、first_or_initializeメソッドを使用 tag.save PostTagRelation.create(post_id: post.id, tag_id: tag.id) # tagとpostの紐付けの情報を、中間テーブルに保存 end end
- ストロングパラメーターに
tag_name
を追加するapp/controllers/posts_controller.rbprivate def post_form_params params.require(:post_form).permit(:text, :tag_name, :image) end
image
の前に追加するように注意 - タグを入力するフォームを追加するため、
app/views/posts/_form.html.erb
を編集する<%#= form_with model: @post, id: 'new_post', local: true do |f| %> <%= form_with model: @post_form, url: url, method: method, id: 'new_post', local: true do |f| %> <%= render 'shared/error_messages', model: f.object %> <div class="message-field"> <%= f.text_field :text, placeholder: 'type a message' %> </div> <%#= タグ入力フォーム%> <div class="tag-field"> <%= f.text_field :tag_name, placeholder: 'add tags' %> </div>
- 詳細ページでタグの情報を表示できるようにするため、
app/views/posts/show.html.erb
を編集する※タグは任意項目なので、タグが存在している時のみ、タグ名を表示する<tbody> <tr> <th>名前</th> <td><%= @post.text%></td> </tr> <!-- タグ項目を追加 --> <tr> <th>タグ</th> <td><%= @post.tags.first&.tag_name %></td> </tr> </tbody>
- ブラウザ確認
手順5(タグを一緒に編集できるようにする)
- tagが存在する場合、編集画面の入力フォームにタグ名が表示されるようにするため、editアクションを編集する
app/controllers/posts_controller.rb
# 省略 def edit # @postから情報をハッシュとして取り出す post_attributes = @post.attributes # @post_formとしてインスタンス生成 @post_form = PostForm.new(post_attributes) # tagがあれば、入力フォームにタグ名を表示 @post_form.tag_name = @post.tags.first&.tag_name end # 省略
- データ更新処理を変更する
app/models/post_form.rb
# 省略 # フォームから送られてきた情報を使用して、レコードを更新する処理 def update(params, post) #post.update(params) #一度タグの紐付けを消す post.post_tag_relations.destroy_all #paramsの中のタグの情報を削除。同時に、返り値としてタグの情報を変数に代入 tag_name = params.delete(:tag_name) #もしタグの情報がすでに保存されていればインスタンスを取得、無ければインスタンスを新規作成 tag = Tag.where(tag_name: tag_name).first_or_initialize if tag_name.present? #タグを保存 tag.save if tag_name.present? post.update(params) PostTagRelation.create(post_id: post.id, tag_id: tag.id) if tag_name.present? end end
- ブラウザで確認する
手順6(インクリメンタルサーチを実装する)
- インクリメンタルサーチの実装準備を行う
- コントローラーに、searchアクションを定義する
app/controller/posts_controller.rb
# 省略 def search # フォームの入力内容が空であれば、Javascriptにnilを返す return nil if params[:keyword] == "" # フォームの入力内容をもとに、あいまい検索。DB内に一致するものがあれば、変数tagに情報を代入 tag = Tag.where(['tag_name LIKE ?', "%#{params[:keyword]}%"] ) # json形式で、変数tagの情報をJavascriptに返す render json:{ keyword: tag } end private # 省略
- searchアクションのルーティングを設定する
config/routes.rb
Rails.application.routes.draw do root "posts#index" resources :posts, only: [:new, :create, :show, :edit, :update] do collection do get 'search' end end end
- インクリメンタルサーチの結果を表示するスペースを作るため、
app/views/posts/_form.html.erb
を編集する<%#= タグ入力フォーム%> <div class="tag-field"> <%= f.text_field :tag_name, placeholder: 'add tags' %> <div id="search-result"></div><%#= インクリメンタルサーチの結果を表示するスペース%> </div>
- CSSファイルを編集する
app/assets/stylesheets/show.css
/* 省略 */ #search-result{ width: 30%; } .child{ margin: 5px; border: 1px solid #dedede; background-color: #F4F4F4; padding: 5px; font-size: 12px; }
- インクリメンタルサーチが実装できているか確認するため、タグを複数登録する
- コントローラーに、searchアクションを定義する
- 非同期通信を行うまでの実装を行う
-
app/javascript/tag.js
を作成する - JSファイルを読み込むため、
config/importmap.rb
を編集するconfig/importmap.rb# 最終行に追記 pin "tag", to: "tag.js"
- JSファイルを読み込むため、
app/javascript/application.js
を編集するapp/javascript/application.js// 最終行に追記 import "tag"
- JSファイルが読み込めているかを確認するため、JSファイルを編集する
app/javascript/tag.js
console.log("読み込み完了");
- ブラウザのコンソール画面にて、「読み込み完了」が表示されることを確認する
- JSファイルの読み込み対象を限定するため、JSファイルを編集する
app/javascript/tag.js
document.addEventListener("turbo:load", () => { // タグの入力フォームのidを指定し、タグの要素を取得 const tagNameInput = document.querySelector("#post_form_tag_name"); if (tagNameInput){ // tagNameInputが存在する画面の場合のみ、読み込まれる console.log("読み込み完了"); }; });
- 新規投稿画面・編集画面のみJSファイルが読み込まれることを確認する
- タグの検索に必要な情報を取得する
app/javascript/tag.js
// 省略 if (tagNameInput){ // inputElementを定義 const inputElement = document.getElementById("post_form_tag_name"); // フォームに文字が入力されたときに発火する関数を定義 inputElement.addEventListener("input", () => { // フォームに入力されている文字列を変数keywordに代入 const keyword = document.getElementById("post_form_tag_name").value; // 変数keywordの中身を確認する console.log(keyword); }); }; });
- 変数keywordの中身をブラウザで確認する
- XMLHttpRequestオブジェクトを生成する
app/javascript/tag.js
// 省略 // フォームに入力されている文字列を変数keywordに代入 const keyword = document.getElementById("post_form_tag_name").value; // 非同期通信に必要なXMLHttpRequestオブジェクトを生成し、変数XHRに代入 const XHR = new XMLHttpRequest(); }); }; });
- searchアクションへのリクエストを定義する
app/javascript/tag.js
// 省略 // 非同期通信に必要なXMLHttpRequestオブジェクトを生成し、変数XHRに代入 const XHR = new XMLHttpRequest(); // searchアクションへのリクエストを定義 XHR.open("GET", `/posts/search/?keyword=${keyword}`, true); }); }; });
- レスポンスの形式を指定する
app/javascript/tag.js
// 省略 // searchアクションへのリクエストを定義 XHR.open("GET", `/posts/search/?keyword=${keyword}`, true); // コントローラーから返却されるデータの形式は、jsonを指定 XHR.responseType = "json"; }); }; });
- リクエストを送信する記述を追記する
app/javascript/tag.js
// 省略 // コントローラーから返却されるデータの形式は、jsonを指定 XHR.responseType = "json"; // リクエストを送信 XHR.send(); }); }; });
- ブラウザで文字を入力すると、サーバーにてリクエストが送られていることを確認する
-
- 非同期通信後の処理を実装する
- 非同期通信が成功したときに呼び出される関数を設定する
app/javascript/tag.js
// 省略 // リクエストを送信 XHR.send(); // 非同期通信が成功したときに呼び出される関数を設定 XHR.onload = () => { console.log("非同期通信成功"); }; }); }; });
- ブラウザでタグの入力フォームに文字を入力すると、「非同期通信成功」と表示されることを確認する
- サーバーサイドからのレスポンスを受け取る
app/javascript/tag.js
// 省略 // 非同期通信が成功したときに呼び出される関数を設定 XHR.onload = () => { // サーバーサイドの処理が成功したときにレスポンスとして返ってくるデータを受け取る const tagName = XHR.response.keyword; }; }); }; });
- タグを表示させる処理を記述する
app/javascript/tag.js
// 省略 // サーバーサイドの処理が成功したときにレスポンスとして返ってくるデータを受け取る const tagName = XHR.response.keyword; // HTMLを作成し、タグを表示する const searchResult = document.getElementById("search-result"); // idを取得 // 検索結果があるだけ繰り返す tagName.forEach((tag) => { const childElement = document.createElement("div"); // タグを表示させるための要素を生成 childElement.setAttribute("class", "child"); // 生成した要素にclassを指定 childElement.setAttribute("id", tag.id); // 生成した要素にidを指定 childElement.innerHTML = tag.tag_name; // 生成した要素の内容に検索結果のタグ名を指定 searchResult.appendChild(childElement); // 生成したchildElement要素をsearchResult要素に挿入 }); }; }); }; });
- ブラウザにて、インクリメンタルサーチが実装されたことを確認する
- クリックしたタグ名がフォームに入力されるようにする
app/javascript/tag.js
// 省略 searchResult.appendChild(childElement); // 生成したchildElement要素をsearchResult要素に挿入 // クリックしたタグ名がフォームに入力される const clickElement = document.getElementById(tag.id); // clickイベントを指定 clickElement.addEventListener("click", () => { // フォームにタグ名を入力 document.getElementById("post_form_tag_name").value = clickElement.textContent; // タグを表示している要素を削除 clickElement.remove(); }); }); }; }); }; });
- ブラウザで表示されたタグをクリックすると、クリックしたタグが消えて、フォームにクリックしたタグ名が入力されることを確認する
- 直前の検索結果を消すように修正する
app/javascript/tag.js
// 省略 // HTMLを作成し、タグを表示する const searchResult = document.getElementById("search-result"); // idを取得 // 直前の検索結果を消す searchResult.innerHTML = ""; // 検索結果があるだけ繰り返す tagName.forEach((tag) => { // 省略
- ブラウザで確認
- レスポンスにデータが存在する場合のみ処理を実行するように編集する
app/javascript/tag.js
// 非同期通信が成功したときに呼び出される関数を設定 XHR.onload = () => { // HTMLを作成し、タグを表示する const searchResult = document.getElementById("search-result"); // idを取得 // 直前の検索結果を消す searchResult.innerHTML = ""; // レスポンスにデータが存在する場合のみ、タグを表示させる if (XHR.response) { // サーバーサイドの処理が成功したときにレスポンスとして返ってくるデータを受け取る const tagName = XHR.response.keyword; // 検索結果があるだけ繰り返す tagName.forEach((tag) => { const childElement = document.createElement("div"); // タグを表示させるための要素を生成 childElement.setAttribute("class", "child"); // 生成した要素にclassを指定 childElement.setAttribute("id", tag.id); // 生成した要素にidを指定 childElement.innerHTML = tag.tag_name; // 生成した要素の内容に検索結果のタグ名を指定 searchResult.appendChild(childElement); // 生成したchildElement要素をsearchResult要素に挿入 // クリックしたタグ名がフォームに入力される const clickElement = document.getElementById(tag.id); // clickイベントを指定 clickElement.addEventListener("click", () => { // フォームにタグ名を入力 document.getElementById("post_form_tag_name").value = clickElement.textContent; // タグを表示している要素を削除 clickElement.remove(); }); }); } }; }); }; });
- ブラウザでタグ項目に入力した文字を削除しても、コンソール上でエラーが発生しない
- 非同期通信が成功したときに呼び出される関数を設定する