LoginSignup
152
162

More than 5 years have passed since last update.

Railsでタグ機能をgemを使わずに実装した際のメモ

Last updated at Posted at 2017-05-07

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モデルについては実装内容によって変わってくると思いますので、参考までに。

記事テーブル

XXXXXXXXXXXXXX_create_articles.rb
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

カテゴリーテーブル

XXXXXXXXXXXXXX_create_categories.rb
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

中間テーブル

XXXXXXXXXXXXXX_create_article_categories.rb
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

モデル

基本的に教科書通りの多対多の実装です。

関連付け

article.rb
class Article < ApplicationRecord
...(省略)
  has_many :categories, through: :article_categories
  has_many :article_categories, dependent: :destroy
...(省略)
category.rb
class Category < ApplicationRecord
...(省略)
  validates :name,presence:true,length:{maximum:50}
  has_many :articles, through: :articles_categories
  has_many :articles_categories, dependent: :destroy
end
...(省略)
article_category.rb
class ArticleCategory < ApplicationRecord
  belongs_to :article
  belongs_to :category
  validates :article_id,presence:true
  validates :category_id,presence:true
end

カテゴリータグ更新処理

要である更新処理はarticleモデルに記載します。

article.rb
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を参考に、不要な部分を省いて実装します。
処理の流れを解説させていただきますと、

  1. 現在記事に登録されているカテゴリーの取得
  2. 古いカテゴリータグ(現在記事に登録されているタグ - 登録処理に渡されたタグ)の取得
  3. 新しいタグ(登録処理に渡されたタグ - 現在記事登録されているタグ)の取得
  4. 古いタグをarticleから削除
  5. 新しいタグがカテゴリーテーブルに登録されていなかったら新規登録
  6. 新しいタグをarticleに追加

あとはこの更新処理を記事登録・更新の際に挟んでいけばOKです。

ビュー

コントローラーの前に登録の窓口となるビューから先に実装します。
tag-itの導入についてはacts-as-taggable-on と jQUery Tag-it でタグ付け機能作成
を参考にしてください。
以降tag-it実装済の前提で説明させていただきます。

登録画面

new.html.erb
<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 %>

更新画面

new.html.erb
<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を使って実装しています。

articles.coffee
$(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))}
...(省略)

まとめ

全体的にもっとスマートにする方法はあるかと思いますが、基本的な方針として説明してみました。
実際に稼働しているコードからテーブル名を汎用的なものに修正した内容となっております為、細かな記載ミスがあるかもしれません。
見つけた際はお手数ですが、ご指摘いただければ幸いです。

152
162
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
152
162