🦓

[Rails]タグ 1/2

2023/07/13に公開

はじめに

投稿にタグを追加できる機能を実装していきます。

環境:

Rails 6.1.7.3
ruby 3.0.0

Tagモデルを作成する

タグを保存するタグテーブルを作成します。

bin/rails generate model Tag name:string 
      create    db/migrate/20230713005537_create_tags.rb
      create    app/models/tag.rb
db/migrate/xxx_create_tags.rb
class CreateTags < ActiveRecord::Migration[6.1]
  def change
    create_table :tags do |t|
      t.string :name, null: false

      t.timestamps
    end
  end
end
bin/rails db:migrate
Running via Spring preloader in process 3298
== 20230713005537 CreateTags: migrating =======================================
-- create_table(:tags)
   -> 0.0031s
== 20230713005537 CreateTags: migrated (0.0032s) ==============================

has_and_belongs_to_manyについて

has_and_belongs_to_manyは、多対多(Many-to-Many)の関係を表現するために使用されます。このタイプの関連では、2つのモデル間に中間テーブル(またはジョインテーブル)が存在し、各モデルは中間テーブルを通じて他方の複数のインスタンスと関連付けることができます。

has_many :throughは、より複雑な関連性を表現するために使用されます。この関連タイプでは、2つのモデル間に中間モデル(または結合モデル)が存在し、中間モデルが追加の属性を持つことができます。

has_and_belongs_to_manyは中間テーブルを使用し、中間モデルを持たずに多対多の関係を表現します。一方、has_many :throughは中間モデルを介して多対多の関係を表現します。中間モデルを介することで、関連に情報を追加できます。

https://railsguides.jp/association_basics.html#has-many-throughとhas-and-belongs-to-manyのどちらを選ぶか

タグと投稿を紐付ける

一つの投稿が複数のタグを登録することができます、一つのタグが複数の投稿に登録されることができます。どちらも一対多の関係なので中間テーブルを作成します。

rails generate model ArticleTag article:references tag:references
      create    db/migrate/20230713013532_create_article_tags.rb
      create    app/models/article_tag.rb

https://guides.rubyonrails.org/active_record_migrations.html#creating-a-join-table

db/migrate/xxx_create_article_tags.rb
class CreateArticleTags < ActiveRecord::Migration[6.1]
  def change
    create_table :article_tags do |t|
      t.references :article, null: false, foreign_key: true
      t.references :tag, null: false, foreign_key: true
      t.timestamps
    end
  end
end
bin/rails db:migrate
Running via Spring preloader in process 3182
== 20230713013532 CreateArticleTags: migrating ================================
-- create_table(:article_tags)
   -> 0.0045s
== 20230713013532 CreateArticleTags: migrated (0.0046s) =======================

モデルファイルに関連付けを設定する

dependent: :destroyを指定することで投稿を削除されるとarticle_tagsに対応したレコードも一緒に削除されます。

app/models/article.rb
class Tag < ApplicationRecord
    has_many :article_tags, dependent: :destroy
    has_many :articles, through: :article_tags

    before_validation :downcase_name
    validates :name, presence: true, uniqueness: { case_sensitive: false }

    private

    def downcase_name
        self.name = name.downcase if name.present?
    end
end

ユーザーがrailsRailsなどと入力しても、それが小文字に変換されてデータベースに保存されるようにします。データベース内でのタグ名の一貫性を保ちつつ、大文字と小文字の違いによる重複を回避したいためbefore_validationのメソッドを追加します。

既にタグテーブルに登録されているタグが存在する場合は、新たに登録するのではなく、既存のものを使用しますのでunique制約をかけます。

uniqueness: { case_sensitive: false }は、Railsのバリデーションオプションの1つであり、属性の値の一意性を検証する際に大文字と小文字を区別しないようにします。

app/models/tag.rb
class Tag < ApplicationRecord
  has_many :article_tags, dependent: :destroy
  has_many :articles, through: :post_tags
end
app/models/article_tag.rb
class ArticleTag < ApplicationRecord
  belongs_to :article
  belongs_to :tag
end
app/models/article.rb
class Article < ApplicationRecord
   has_many :article_tags, dependent: :destroy
   has_many :tags, through: :article_tags
end

Articlesコントローラーを設定する

タグは投稿のパラメーターではないのでparams[:article][:tag_names]で呼び出します。
split("\n")はタグを改行で区切って、文字列を分割してサブストリングを作成するメソッドです。""ダブルquoteじゃないとエスケープされないので気を付けましょう。
ユーザーが改行で区切って入力したタグ文字例をmapメソッドで配列を作成します。
stripメソッドはタグ前後のスペースを削除してくれます。
uniqメソッドは配列から重複した要素を取り除いた新しい配列を返します。
uniq!を使うと重複した要素がない場合タグがnilになってしまいエラーが発生します。

https://docs.ruby-lang.org/ja/latest/method/Array/i/uniq.html

app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
...
  def create
    @article = Current.user.articles.build(article_params)
    tag_names = params[:article][:tag_names]
    
    if @article.save
      if tag_names.present?
        tags = tag_names.split("\n").map(&:strip).uniq
        create_or_update_article_tags(@article, tags)
      end
      flash[:success] = t('defaults.message.created', item: Article.model_name.human)
      redirect_to @article
    else
      flash.now[:danger] = t('defaults.message.not_created', item: Article.model_name.human)
      render :new
    end
  end

  def update
    tag_names = params[:article][:tag_names]
    
    if @article.update(article_params)
      if tag_names.present?
        tags = params[:article][:tag_names].split("\n").map(&:strip).uniq
        create_or_update_article_tags(@article, tags)
      end
      flash[:success] = t('defaults.message.updated', item: Article.model_name.human)
      redirect_to @article
    else
      flash.now[:danger] = t('defaults.message.not_updated', item: Article.model_name.human)
      render :edit
    end
  end

  private

  def create_or_update_article_tags(article, tags)
    article.tags.destroy_all
    begin
    tags.each do |tag|
      tag = Tag.find_or_create_by(name: tag)
      article.tags << tag
      rescue ActiveRecord::RecordInvalid
        false
      end
    end
  end
end

destroy_all

article.tags.destroy_allタグを編集される場合、古いタグが残されるので一度destroy_allで現在のタグを全部削除し、updateアクションによるタグを保存し直します。

find_or_create_by

find_or_create_byはレコードが既に存在する場合は既存のレコードを取得し、そうでない場合は新しく作成するメソッドです。
https://railsdoc.com/page/find_or_create_by

ActiveRecord::RecordInvalid

タグモデルでdowncaseメソッドを作成しましたので、同じタグ名を大文字(TAG)と小文字(tag)で2回入力される場合、バリデーションを引っかかってしまいますのでレスキューします。
そうすると大文字のタグはDBに保存されず、小文字のタグ(tag)だけ保存されます。

投稿フォームにタグフィールドを追加する

app/views/articles/_form.html.erb
<%= form_with model: @article do |form| %>
...
  <div class="form-group mt-3 mb-3">
    <%= form.label :tag_names %>
    <%= form.text_area :tag_names, 
    class: 'tag-input form-control', rows: 2 %>
  </div>
<% end %>

投稿詳細ビューにタグを表示させる

一覧ページにも表示させたいためパーシャルを作成します。

app/views/articles/_tags.html.erb
<% article.tags.each do |tag| %>
  <span class="badge rounded-pill text-bg-dark"><%= tag.name %></span>
<% end %>
app/views/articles/show.html.erb
<% if @article.tags.present? %>
  <div class="mb-3">
    <%= render 'articles/tags', article: @article %>
  </div>
<% end %>

投稿一覧ビューにタグを表示させる

app/views/articles/_article.html.erb
<% if @article.tags.present? %>
  <div class="mb-3">
    <%= render 'articles/tags', article: article %>
  </div>
<% end %>

登録済みのタグを編集画面に表示させる

投稿を編集する場合、タグフィールドに登録済みのタグを入力フィールドに表示させていきます。

app/views/articles/_form.html.erb
<%= form_with model: @article do |form| %>
...
  <div class="form-group mt-3 mb-3">
    <%= form.label :tag_names %>
    <%= form.text_area :tag_names, 
    class: 'tag-input form-control', 
    value: article.tags.map(&:name).join("\n"), 
    rows: 2 %>
  </div>
<% end %>

irb(main):001:0> article = Article.last
   (1.2ms)  SELECT sqlite_version(*)
  Article Load (0.1ms)  SELECT "articles".* FROM "articles" ORDER BY "articles"."id" DESC LIMIT ?  [["LIMIT", 1]]
=> #<Article id: 44, title: "tag", body: "post tag", created_at: "2023-07-13 16:26:12.586120000 ...
irb(main):002:0> article.tags
  Tag Load (0.2ms)  SELECT "tags".* FROM "tags" INNER JOIN "article_tags" ON "tags"."id" = "article_tags"."tag_id" WHERE "article_tags"."article_id" = ? /* loading for inspect */ LIMIT ?  [["article_id", 44], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Tag id: 3, name: "tag", created_at: "2023-07-13 14:08:12.327043000 +0900", updated_at: "2023-07-13 14:08:12.327043000 +0900">, #<Tag id: 11, name: "tag1", created_at: "2023-07-13 16:55:51.627676000 +0900", updated_at: "2023-07-13 16:55:51.627676000 +0900">]>
irb(main):003:0> article.tags.map { |value| [value.name] }
=> [["tag"], ["tag1"]]

タグのプレビューを作成する

app/javascripts/tag-preview.js
document.addEventListener('DOMContentLoaded', function() {
// Function to handle input change event
function handleInputChange(event) {
    if (event.key === 'Enter') {
      var tagInput = document.querySelector('.tag-input');
      var tagContainer = document.getElementById('tag-container');
  
      // Clear the tag container
      tagContainer.innerHTML = '';
  
      // Split the input value into an array of tag names
      var tagNames = tagInput.value.split('\n').map(function(tagName) {
        return tagName.trim();
      });
  
      tagNames.forEach(function(tagName) {
        if (tagName !== '') {
          var badgeWrapper = document.createElement('span');
          badgeWrapper.className = 'mr-1';
  
          var badgeElement = document.createElement('span');
          badgeElement.className = 'badge rounded-pill text-bg-dark';
          badgeElement.textContent = tagName;
  
          var closeButton = document.createElement('button');
          closeButton.className = 'btn';
          closeButton.innerHTML = '&times;';
          closeButton.addEventListener('click', function() {
            badgeWrapper.remove(); // Remove the wrapper containing the badge and the close button
            updateTextAreaValue();
          });
  
          badgeWrapper.appendChild(badgeElement);
          badgeWrapper.appendChild(closeButton);
          tagContainer.appendChild(badgeWrapper);
        }
      });
  
      updateTextAreaValue();
    }
  }
  
  // Function to update the value of the textarea
  function updateTextAreaValue() {
    var tagContainer = document.getElementById('tag-container');
    var tagNames = Array.from(tagContainer.children)
      .map(function(badgeWrapper) {
        return badgeWrapper.firstChild.textContent.trim();
      })
      .join('\n');
  
    var tagInput = document.querySelector('.tag-input');
    tagInput.value = tagNames;
  }
  
  // Add event listener to the text area input for keypress event
  var tagInput = document.querySelector('.tag-input');
  tagInput.addEventListener('keypress', handleInputChange); 
});

タグの訳文を追加する

ローケルファイルにタグ関連の訳文を追加します。

config/locales/activerecord/ja.yml
ja:
  activerecord:
    models:
      tag: 'タグ'
    attributes:
      article:
        tag_names: 'タグ'

終わりに

中間モデルが難しかったですね。
次回の記事ではタグを検索できるように実装したいと思います。

Discussion