Railsでタグ機能を実装したい場合、acts-as-taggable-onというgemを使用するのが一般的。
しかし
- Rails5ではちょこちょこ不具合があがっていて、実装例もなかなか見つからない
- 実装したい内容に対し、いらない機能が結構ある
- カスタマイズがちょっとめんどくさそう
- 関連付けのいい勉強になりそう
という理由から自分で実装してみることにしました。
実装した機能
ユーザー(user)は複数の記事(article)を作成することができ、その記事に対してユーザーは任意のカテゴリータグ(category)を付与することができる。
付与したカテゴリータグでユーザーは投稿の絞り込みを行うことができる。
(ちょうどQiitaの投稿に対するタグ機能のような感じです。)
acts-as-taggable-onではポリモーフィズムを使用して複数テーブルに対し、タグを付与できるようにしていますが、今回は対象テーブルは1つで実装しています。
実装環境
Railsバージョン:5.0.2
DB:MySQL
フロントエンドにはjQueryプラグインのtag-itを使用しました。
tag-itの実装については
acts-as-taggable-on と jQUery Tag-it でタグ付け機能作成
を参考にさせていただきました。
acts-as-taggable-onでタグ機能を実装したいという方にオススメさせていただきたい記事です。
マイグレーション
関連するモデルについてのみ記載していきます。
rails generate model article
rails generate model category
rails generate model article_category
マイグレーションは下記の通り。articleモデルについては実装内容によって変わってくると思いますので、参考までに。
記事テーブル
class CreateArticles < ActiveRecord::Migration[5.0]
def change
create_table :articles do |t|
t.string :title
t.text :body
t.references :user, foreign_key: true
t.timestamps
end
add_index :articles,[:user_id,:created_at]
end
end
カテゴリーテーブル
class CreateCategories < ActiveRecord::Migration[5.0]
def change
create_table :categories do |t|
t.string :name
t.timestamps
end
add_index :categories, :name, unique:true
end
end
中間テーブル
class CreateArticleCategories < ActiveRecord::Migration[5.0]
def change
create_table :article_categories do |t|
t.integer :article_id
t.integer :category_id
t.timestamps
end
add_index :article_categories, :article_id
add_index :article_categories, :category_id
add_index :article_categories, [:article_id,:category_id],unique: true
end
end
そしてマイグレーション
rake db:migrate
モデル
基本的に教科書通りの多対多の実装です。
関連付け
class Article < ApplicationRecord
...(省略)
has_many :categories, through: :article_categories
has_many :article_categories, dependent: :destroy
...(省略)
class Category < ApplicationRecord
...(省略)
validates :name,presence:true,length:{maximum:50}
has_many :articles, through: :articles_categories
has_many :articles_categories, dependent: :destroy
end
...(省略)
class ArticleCategory < ApplicationRecord
belongs_to :article
belongs_to :category
validates :article_id,presence:true
validates :category_id,presence:true
end
カテゴリータグ更新処理
要である更新処理はarticleモデルに記載します。
class Article < ApplicationRecord
...(省略)
def save_categories(tags)
current_tags = self.categories.pluck(:name) unless self.categories.nil?
old_tags = current_tags - tags
new_tags = tags - current_tags
# Destroy old taggings:
old_tags.each do |old_name|
self.categories.delete Category.find_by(name:old_name)
end
# Create new taggings:
new_tags.each do |new_name|
article_category = Category.find_or_create_by(name:new_name)
self.categories << article_category
end
end
...(省略)
acts-as-taggable-onを参考に、不要な部分を省いて実装します。
処理の流れを解説させていただきますと、
- 現在記事に登録されているカテゴリーの取得
- 古いカテゴリータグ(現在記事に登録されているタグ - 登録処理に渡されたタグ)の取得
- 新しいタグ(登録処理に渡されたタグ - 現在記事登録されているタグ)の取得
- 古いタグをarticleから削除
- 新しいタグがカテゴリーテーブルに登録されていなかったら新規登録
- 新しいタグをarticleに追加
あとはこの更新処理を記事登録・更新の際に挟んでいけばOKです。
ビュー
コントローラーの前に登録の窓口となるビューから先に実装します。
tag-itの導入についてはacts-as-taggable-on と jQUery Tag-it でタグ付け機能作成
を参考にしてください。
以降tag-it実装済の前提で説明させていただきます。
登録画面
<h2>記事の新規作成</h2>
<%= form_for(@article) do |f| %>
<div class="field">
<%= f.label :title %>
<%= f.text_field :title %>
<div class="article-tags-field">
<%= f.label "カテゴリー" %>
<ul id="article-tags">
</ul>
</div>
<%= f.label :body %>
<%= f.text_field :body %>
<%= f.submit "記事作成" %>
</div>
<% end %>
更新画面
<h2>記事の更新</h2>
<%= form_for(@article) do |f| %>
<div class="field">
<%= f.label :title %>
<%= f.text_field :title %>
</div>
<div class="article-tags-field">
<%= f.label "カテゴリ" %>
<ul id="article-tags">
</ul>
<%= hidden_field_tag :category_hidden,@category_list %>
</div>
<div class="field">
<%= f.label :body %>
<%= f.text_field :body %>
</div>
<%= f.submit "更新", class: "button" %>
<% end %>
</div>
実際はリファクタリングすべきですが、説明用ということでご容赦を・・・
空のulタグarticle-tagsがカテゴリーフォームの実装対象となります。
あとはtag-itの有効化、登録済カテゴリーの画面への受け渡しのためのjavascriptを実装します。
最初はgonというgemを使ってjavascriptに値を渡そうとしていたのですが、clearが効かなかったり変に値が残ることがあったので、hiddenを使って実装しています。
$(document).on 'turbolinks:load', ->
$('#article-tags').tagit
fieldName: 'category_list'
singleField: true
$('#article-tags').tagit()
category_string = $("#category_hidden").val()
try
category_list = category_string.split(',')
for tag in category_list
$('#article-tags').tagit 'createTag', tag
catch error
Rails5からturbolinks:loadでいい感じに発火してくれるようになったみたいです。
例外処理については必要に応じて実装してください。
コントローラー
記事情報の登録・更新が成功した際にモデルにおいて実装した更新メソッドを呼び出しています。
class ArticlesController < ApplicationController
...(省略)
def create
@article= current_user.articles.build(articles_params)
category_list = params[:category_list].split(",")
if @article.save
@article.save_categories(category_list)
flash[:success] = "記事を作成しました"
redirect_to articles_url
else
render 'articles/new'
end
end
def edit
@article= Article.find(params[:id])
@category_list = @article.categories.pluck(:name).join(",")
end
def update
@article= Article.find(params[:id])
category_list = params[:category_list].split(",")
if @article.update_attributes(article_params)
@article.save_categories(category_list)
flash[:success] = "記事を更新しました"
redirect_to articles_url
else
render 'edit'
end
end
...(省略)
end
基本的な実装については以上になります。
その他
ビューでリンク付カテゴリリスト表示し、リンク先にてカテゴリで絞り込みした一覧を表示したい場合
ビュー
<% unless @article.categories.blank? %>
<ul>
<% @article.categories.each do |category| %>
<li>
<%= link_to category.name,articles_path(category_id:category.id) %>
</li>
<% end %>
</ul>
<% end %>
コントローラー
...(省略)
def index
if params[:category_id]
@selected_category = Category.find(params[:category_id])
@articles= Article.from_category(params[:category_id]).page(params[:page])
else
@articles= Article.all.page(params[:page])
end
end
...(省略)
モデル(スコープ)
...(省略)
scope :from_category, -> (category_id) { where(id: article_ids = ArticleCategory.where(category_id: category_id).select(:article_id))}
...(省略)
まとめ
全体的にもっとスマートにする方法はあるかと思いますが、基本的な方針として説明してみました。
実際に稼働しているコードからテーブル名を汎用的なものに修正した内容となっております為、細かな記載ミスがあるかもしれません。
見つけた際はお手数ですが、ご指摘いただければ幸いです。