[Rails]タグ 1/2
はじめに
投稿にタグを追加できる機能を実装していきます。
環境:
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
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
は中間モデルを介して多対多の関係を表現します。中間モデルを介することで、関連に情報を追加できます。
タグと投稿を紐付ける
一つの投稿が複数のタグを登録することができます、一つのタグが複数の投稿に登録されることができます。どちらも一対多の関係なので中間テーブルを作成します。
rails generate model ArticleTag article:references tag:references
create db/migrate/20230713013532_create_article_tags.rb
create app/models/article_tag.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
に対応したレコードも一緒に削除されます。
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
ユーザーがrails
やRails
などと入力しても、それが小文字に変換されてデータベースに保存されるようにします。データベース内でのタグ名の一貫性を保ちつつ、大文字と小文字の違いによる重複を回避したいためbefore_validation
のメソッドを追加します。
既にタグテーブルに登録されているタグが存在する場合は、新たに登録するのではなく、既存のものを使用しますのでunique
制約をかけます。
uniqueness: { case_sensitive: false }
は、Railsのバリデーションオプションの1つであり、属性の値の一意性を検証する際に大文字と小文字を区別しないようにします。
class Tag < ApplicationRecord
has_many :article_tags, dependent: :destroy
has_many :articles, through: :post_tags
end
class ArticleTag < ApplicationRecord
belongs_to :article
belongs_to :tag
end
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
になってしまいエラーが発生します。
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
はレコードが既に存在する場合は既存のレコードを取得し、そうでない場合は新しく作成するメソッドです。
ActiveRecord::RecordInvalid
タグモデルでdowncase
メソッドを作成しましたので、同じタグ名を大文字(TAG
)と小文字(tag
)で2回入力される場合、バリデーションを引っかかってしまいますのでレスキューします。
そうすると大文字のタグはDBに保存されず、小文字のタグ(tag
)だけ保存されます。
投稿フォームにタグフィールドを追加する
<%= 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 %>
投稿詳細ビューにタグを表示させる
一覧ページにも表示させたいためパーシャルを作成します。
<% article.tags.each do |tag| %>
<span class="badge rounded-pill text-bg-dark"><%= tag.name %></span>
<% end %>
<% if @article.tags.present? %>
<div class="mb-3">
<%= render 'articles/tags', article: @article %>
</div>
<% end %>
投稿一覧ビューにタグを表示させる
<% if @article.tags.present? %>
<div class="mb-3">
<%= render 'articles/tags', article: article %>
</div>
<% end %>
登録済みのタグを編集画面に表示させる
投稿を編集する場合、タグフィールドに登録済みのタグを入力フィールドに表示させていきます。
<%= 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"]]
タグのプレビューを作成する
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 = '×';
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);
});
タグの訳文を追加する
ローケルファイルにタグ関連の訳文を追加します。
ja:
activerecord:
models:
tag: 'タグ'
attributes:
article:
tag_names: 'タグ'
終わりに
中間モデルが難しかったですね。
次回の記事ではタグを検索できるように実装したいと思います。
Discussion