タグ付けを行う前の前がき
本記事の概要
・投稿にタグ付けをできるようにする。
・文字を入力する度に自動検索を行ってくれる機能(インクリメンタルサーチ)をタグにを実装する
開発環境
Mac OS Catalina 10.15.4
ruby 2.6系
rails 6.0系
※rails newでアプリケーションは作成済みであることを前提としています。
タグ付け機能の完成形イメージ
上図のGifのように、タグを入力し始めるとDBに保存されているタグを元にオススメタグを表示できるようにしています。
今回の記事を元にタグ付け機能を実装できれば、タグ検索なども容易に実装できるかと思います。
タグ付け機能実装の流れ
1. Tag,Post,PostTagRelation、Userモデルを作成
2. 各種モデルのmigrationファイルを編集
3. Formオブジェクトを導入
4. ルーティングの設定
5. postsコントローラーを作成、アクション定義
6. ビューファイルの作成
7. インクリメンタルサーチの実装(JavaScript)
上記の手順で実装を行ってきます。
1.Tag,Post,PostTagRelation,Userモデルを作成
まずは、各種モデルを導入しましょう。
% rails g model tag
% rails g model post
% rails g model post_tag_relation
% rails g devise user
そのまま、導入した各モデルを関連付け(アソシエーション)してバリデーションを記述しましょう。
class Post < ApplicationRecord
has_many :post_tag_relations
has_many :tags, through: :post_tag_relations
belongs_to :user
end
class Tag < ApplicationRecord
has_many :post_tag_relations
has_many :posts, through: :post_tag_relations
validates :name, uniqueness: true
end
「through: :中間テーブル」とすることで、多対多の関係であるPostモデルとTagモデルのアソシエーションを組んでいます。
注意点としては、throughによる参照前に中間テーブルの紐付けを行う必要があります。
(コードは上から読み込まれるので、 has_many :posts, through: :post_tag_relations → has_many :post_tag_relationsの順で書いてしまうとエラーになります。)
class PostTagRelation < ApplicationRecord
belongs_to :post
belongs_to :tag
end
class User < ApplicationRecord
#<省略>
has_many :posts, dependent: :destroy
validates :name, presence: true
Userモデルのhas_manyのオプションに、dependent: :destroyと付けているのは、親要素であるユーザー情報が削除された時にそのヒトの投稿も併せて削除されるようにするためです。
なお、PostモデルとTagモデルにて空データを保存させないようにするための記述(validates :〇〇, presence: true)に関しては、後ほど作成するフォームオブジェクトでまとめて指定しますので、今は必要ありません。
2.各種モデルのmigrationファイルを編集
続いて、作成したモデルにカラムを追加していきます。
(最低限必要なのは、tagのnameカラムくらいなので、その他はお好みでアレンジされてください。)
class CreatePosts < ActiveRecord::Migration[6.0]
def change
create_table :posts do |t|
t.string :title, null: false
t.text :content, null: false
t.date :date
t.time :time_first
t.time :time_end
t.integer :people
t.references :user, foreign_key: true
t.timestamps
end
end
end
postのマイグレーションファイルで外部キーとしてuserを参照しているのは、後ほどuser名を投稿一覧で表示するためです。
class CreateTags < ActiveRecord::Migration[6.0]
def change
create_table :tags do |t|
t.string :name, null: false, uniqueness: true
t.timestamps
end
end
end
上記のnameカラムにuniqueness: trueを適用しているのは、タグ名の重複を防ぐために導入しています。
(タグは同じ名前のものが何度も使われることが想定されるので、重複を防いだらタグ付け機能として成り立たなくない?と思われるかもですが、既存のタグを投稿に反映させる方法は後ほど登場します。)
class CreatePostTagRelations < ActiveRecord::Migration[6.0]
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
このpost_tag_relationモデルが、多対多の関係であるpostモデルとtagモデルの中間テーブルの役割を担っています。
class DeviseCreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
## Database authenticatable
t.string :name, null: false
t.string :email, null: false, default: ""
t.string :encrypted_password, null: false, default: ""
#<省略>
ユーザー名を利用したかったので、nameカラムを追加しました。
カラムの編集が終わったら、忘れずに下記コマンドを実行しましょう。
% rails db:migrate
※まだDBを作成していないという方は、先にrails db:createを実行する必要があります。
3.Formオブジェクトを導入
今回の実装では投稿フォームからpostsテーブルとtagsテーブルへ同時に入力値を保存させたいので、Formオブジェクトを利用します。
まず、appディレクトリの中にformsディレクトリを作り、その中にposts_tag.rbファイルを作成しましょう。
そして、下記のようにpostsテーブルとtagsテーブルに同時に値を保存するためのsaveメソッドを定義します。
class PostsTag
include ActiveModel::Model
attr_accessor :title, :content, :date, :time_first, :time_end, :people, :name, :user_id
with_options presence: true do
validates :title
validates :content
validates :name
end
def save
post = Post.create(title: title, content: content, date: date, time_first: time_first, time_end: time_end, people: people, user_id: user_id)
tag = Tag.where(name: name).first_or_initialize
tag.save
PostTagRelation.create(post_id: post.id, tag_id: tag.id)
end
end
4. ルーティングの設定
続いて、postsコントローラーのindex・new・createアクションを動かすためのルーティングを設定します。
resources :posts, only: [:index, :new, :create] do
collection do
get 'search'
end
end
collection内で定義しているsearchアクションへのルーティングは、インクリメンタルサーチ機能で利用します。
5. postsコントローラーを作成、アクション定義
ターミナルでコントローラを生成します。
% rails g controller posts
生成されたpostsコントローラファイル内のコードは下記のようになります。
class PostsController < ApplicationController
before_action :authenticate_user!, only: [:new]
def index
@posts = Post.all.order(created_at: :desc)
end
def new
@post = PostsTag.new
end
def create
@post = PostsTag.new(posts_params)
if @post.valid?
@post.save
return redirect_to posts_path
else
render :new
end
end
def search
return nil if params[:input] == ""
tag = Tag.where(['name LIKE ?', "%#{params[:input]}%"])
render json: {keyword: tag}
end
private
def posts_params
params.require(:post).permit(:title, :content, :date, :time_first, :time_end, :people, :name).merge(user_id: current_user.id)
end
end
createアクションでは、先程Formオブジェクトで定義したsaveメソッドを使ってPostsモデルとTagsテーブルへposts_paramsで受け取った値を保存しています。
searchアクションでは、JS側で取得したデータ(タグ入力フォームで打ち込まれた文字列)を元に、 where + LIKE句でtagsテーブルからデータを引っ張り出し、reder jsonでJSに返しています。(JSファイルは後ほど登場。)
そういう訳なので、↑のsearchアクションは、インクリメンタルサーチを実装しないのであれば必要ありません。
6.ビューファイルの作成
<%= form_with model: @post, url: posts_path, class: 'registration-main', local: true do |f| %>
<div class='form-wrap'>
<div class='form-header'>
<h2 class='form-header-text'>タイムライン投稿ページ</h2>
</div>
<%= render "devise/shared/error_messages", resource: @post %>
<div class="post-area">
<div class="form-text-area">
<label class="form-text">タイトル</label><br>
<span class="indispensable">必須</span>
</div>
<%= f.text_field :title, class:"post-box" %>
</div>
<div class="long-post-area">
<div class="form-text-area">
<label class="form-text">概要</label>
<span class="indispensable">必須</span>
</div>
<%= f.text_area :content, class:"input-text" %>
</div>
<div class="tag-area">
<div class="form-text-area">
<label class="form-text">タグ</label>
<span class="indispensable">必須</span>
</div>
<%= f.text_field :name, class: "text-box", autocomplete: 'off' %>
</div>
<div>【おすすめタグ】</div>
<div id="search-result">
</div>
<div class="long-post-area">
<div class="form-text-area">
<label class="form-text">イベント日程</label>
<span class="optional">任意</span>
</div>
<div class="schedule-area">
<div class="date-area">
<label>日付</label>
<%= f.date_field :date %>
</div>
<div class="time-area">
<label>開始時刻</label>
<%= f.time_field :time_first %>
<label class="end-time">終了時刻</label>
<%= f.time_field :time_end %>
</div>
</div>
</div>
<div class="register-btn">
<%= f.submit "投稿する",class:"register-blue-btn" %>
</div>
</div>
<% end %>
僕のアプリ実装で使っていたビューファイルをベタ貼りしているため、コードが冗長になっていますが要はフォームの内容を@post等でルーティングに送れていれば問題ありません。
<div class="registration-main">
<div class="form-wrap">
<div class='form-header'>
<h2 class='form-header-text'>タイムライン一覧ページ</h2>
</div>
<div class="register-btn">
<%= link_to "タイムライン投稿ページへ移る", new_post_path, class: :register_blue_btn %>
</div>
<% @posts.each do |post| %>
<div class="post-content">
<div class="post-headline">
<div class="post-title">
<span class="under-line"><%= post.title %></span>
</div>
<div class="more-list">
<%= link_to '編集', edit_post_path(post.id), class: "edit-btn" %>
<%= link_to '削除', post_path(post.id), method: :delete, class: "delete-btn" %>
</div>
</div>
<div class="post-text">
<p>■概要</p>
<%= post.content %>
</div>
<div class="post-detail">
<% if post.time_end != nil && post.time_first != nil %>
<p>■日程</p>
<div class="post-date">
<%= post.date %>
<%= post.time_first.strftime("%H時%M分") %> 〜
<%= post.time_end.strftime("%H時%M分") %>
</div>
<% end %>
<div class="post-user-tag">
<div class="post-user">
<% if post.user_id != nil %>
■投稿者: <%= link_to "#{post.user.name}", user_path(post.user_id), class:'user-name' %>
<% end %>
</div>
<div class="post-tag">
<% post.tags.each do |tag| %>
#<%= tag.name %>
<% end %>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
こちらも同様に冗長なので、適宜必要なところだけ参照ください...
## 7.インクリメンタルサーチの実装(JavaScript)
こちらは、JSファイルをいじります。
if (location.pathname.match("posts/new")){
window.addEventListener("load", (e) => {
const inputElement = document.getElementById("post_name");
inputElement.addEventListener('keyup', (e) => {
const input = document.getElementById("post_name").value;
const xhr = new XMLHttpRequest();
xhr.open("GET", `search/?input=${input}`, true);
xhr.responseType = "json";
xhr.send();
xhr.onload = () => {
const tagName = xhr.response.keyword;
const searchResult = document.getElementById('search-result')
searchResult.innerHTML = ''
tagName.forEach(function(tag){
const parentsElement = document.createElement('div');
const childElement = document.createElement("div");
parentsElement.setAttribute('id', 'parents')
childElement.setAttribute('id', tag.id)
childElement.setAttribute('class', 'child')
parentsElement.appendChild(childElement)
childElement.innerHTML = tag.name
searchResult.appendChild(parentsElement)
const clickElement = document.getElementById(tag.id);
clickElement.addEventListener('click', () => {
document.getElementById("post_name").value = clickElement.textContent;
clickElement.remove();
})
})
}
});
})
};
location.pathname.matchを使って、postsコントローラのnewアクションが発火した時に、コードが読み込まれるようにしています。
JS内のおおまかな処理としては、
①keyupでイベント発火させて、タグフォームの入力値をコントローラーへ送る(xhr.〇〇辺り)
②xhr.onload以下でコントローラーから返ってきた情報を元に、予測タグをフロントに表示させる。
③予測タグがクリックされたら、そのタグがフォームに反映される。
以上で、タグ付け機能の実装とインクリメンタルサーチの実装ができました。
ざっくりとした記事にはなりますが、最後までお読み頂きありがとうございました!