概要
RailsでActsAsTaggableOnとTagifyを用いたタグ機能のサンプルコードです。
記事にタグを登録する機能とタグを入力時のサジェスト機能があるシンプルなアプリになっています。
バックエンドの処理を簡単に実装するためにActsAsTaggableOnを、見た目を整えるためにTagifyとBulmaを使用しています。
タグの詳細ページではURLのパラメーターにタグの名前を使用しています。
記事に登録できるタグの数とタグの名前の長さや使える文字のバリデーションやRSpecでバリデーションのモデルスペックやサジェスト機能のリクエストスペック、タグ登録のシステムスペックも書いてあります。
バリデーションはForemを参考にしました。
GitHubリポジトリ
デプロイ済みのアプリ
バージョン
Ruby 2.7.4
Rails 6.1.4.1
acts-as-taggable-on 8.1.0
Node 14.17.6
yarn 1.22.10
@yaireo/tagify 4.8.1
bulma 0.9.3
ER図
準備
下記のコマンドで記事に関するコードを作成しました。
$ rails g scaffold articles title:string body:text
ActsAsTaggableOnやTagify、Bulmaはドキュメントに従ってインストールしました。
TagifyとBulmaはNode moduleとしてインストールしています。
ルーティング
タグのURLのパラメーターには名前が使用されるようになっています。
Rails.application.routes.draw do
root to: 'articles#index'
resources :articles
resources :tags, only: %i[index show], param: :name
end
ActsAsTaggableOnの設定
アルファベットが小文字で保存されるようにしています。
ActsAsTaggableOn.force_lowercase = true
コントローラー
articles_controller.rb
privateメソッド内のarticle_paramsにtag_listを追加しています。
class ArticlesController < ApplicationController
.
.
.
private
.
.
.
def article_params
params.require(:article).permit(:title, :body, :tag_list)
end
end
tags_controller.rb
tags_controller.rbのindexアクションはタグ入力時のサジェスト機能のためのアクションです。
class TagsController < ApplicationController
def index
tags = ActsAsTaggableOn::Tag.where('name LIKE ?', "%#{params[:name]}%").pluck(:name).first(5)
render json: { status: 'SUCCESS', message: 'Loaded tags', data: tags }
end
def show
@tag = ActsAsTaggableOn::Tag.find_by(name: params[:name])
@tagged_articles = Article.tagged_with(@tag.name)
end
end
モデル
article.rb
validate_tagメソッドで記事に登録できるタグの数のバリデーションやtag.rbのvalidate_nameメソッドを呼び出してタグの名前に関するバリデーションを行っています。
class Article < ApplicationRecord
acts_as_taggable_on :tags
validate :validate_tag
private
TAG_MAX_COUNT = 5
def validate_tag
if tag_list.size > TAG_MAX_COUNT
return errors.add(:tag_list, :too_many_tags, message: "は#{TAG_MAX_COUNT}つ以下にしてください")
end
tag_list.each do |tag_name|
tag = Tag.new(name: tag_name)
tag.validate_name
tag.errors.messages[:name].each { |message| errors.add(:tag_list, message) }
end
end
end
tag.rb
タグの名前の長さは30文字以内で、ひらがなと全角のカタカナ、漢字、半角の英数字のみが使用できるようなバリデーションを定義しています。
class Tag < ActsAsTaggableOn::Tag
validate :validate_name
TAG_NAME_MAX_LENGTH = 30
TAG_NAME_REGEX = /\A[\w一-龠々ぁ-ヶー]+\Z/.freeze
def validate_name
errors.add(:name, "は#{TAG_NAME_MAX_LENGTH}文字以内で入力してください") if name.length > TAG_NAME_MAX_LENGTH
errors.add(:name, 'はひらがな、または全角のカタカナ、漢字、半角の英数字のみが使用できます') unless name.match?(TAG_NAME_REGEX)
end
end
ビュー
app/views/articles/_form.html.erb
タグの入力欄を追加し、Bulmaのスタイルを適用しています。
.height-unsetは入力欄の高さのBulmaのスタイルを打ち消していて、.custom-tagify-lookはTagifyのスタイルを調整しています。
<div class="columns">
<div class="column is-6">
<%= form_with(model: article) do |form| %>
.
.
.
<div class="field">
<%= form.label :body, class: 'label' %>
<div class="control">
<%= form.text_area :body, class: 'textarea' %>
</div>
</div>
<div class="field">
<%= form.label :tag_list, class: 'label' %>
<div class="control">
<%= form.text_field :tag_list, class: 'input height-unset custom-tagify-look', value: form.object.tag_list.to_s %>
</div>
</div>
<div class="actions">
<%= form.submit class: 'button' %>
</div>
<% end %>
</div>
</div>
app/views/articles/index.html.erb
表をapp/views/tags/show.html.erbでも使えるように_articles.html.erbという部分テンプレートにしています。
<p id="notice"><%= notice %></p>
<h1>Articles</h1>
<%= render 'articles/articles', articles: @articles %>
<br>
<%= link_to 'New Article', new_article_path %>
app/views/articles/_articles.html.erb
Bulmaのスタイルを適用しています。
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Body</th>
<th>Tags</th>
<th colspan="3"></th>
</tr>
</thead>
<tbody>
<% articles.each do |article| %>
<tr>
<td><%= article.title %></td>
<td><%= article.body %></td>
<td>
<% article.tags.each do |tag| %>
<%= link_to tag_path(tag.name) do %>
<span class="tag"><%= tag %></span>
<% end %>
<% end %>
</td>
<td><%= link_to 'Show', article %></td>
<td><%= link_to 'Edit', edit_article_path(article) %></td>
<td><%= link_to 'Destroy', article, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>
<% end %>
</tbody>
</table>
app/views/articles/show.html.erb
TagsをTitleやBodyと同じように追加しています。
.
.
.
<p>
<strong>Body:</strong>
<%= @article.body %>
</p>
<p>
<strong>Tags:</strong>
<% @article.tags.each do |tag| %>
<%= link_to tag_path(tag.name) do %>
<span class="tag"><%= tag %></span>
<% end %>
<% end %>
</p>
<br>
<%= link_to 'Edit', edit_article_path(@article) %> |
<%= link_to 'Articles', articles_path %>
app/views/tags/show.html.erb
<h1><%= @tag.name %></h1>
<p><%= "#{@tagged_articles.size}件の記事" %></p>
<%= render 'articles/articles', articles: @tagged_articles %>
<%= link_to 'Articles', articles_path %>
app/javascript/packs/application.js
app/javascript/src/handleTag.jsを読み込んでいます。
.
.
.
ActiveStorage.start()
import handleTag from '../src/handleTag'
handleTag();
app/javascript/src/handleTag.js
アウトプットのフォーマットをActsAsTaggableOnに合うようにコンマ区切りに変更しています。
.cardはBulmaのスタイル用のクラスです。
fetch()でtags#indexにリクエストを送信してサジェスト機能を実現しています。
import Tagify from '@yaireo/tagify'
export default () => {
document.addEventListener('turbolinks:load', () => {
const tagInput = document.getElementById('article_tag_list')
if (tagInput === null) return false;
const tagify = new Tagify(tagInput, {
originalInputValueFormat: valuesArr => valuesArr.map(item => item.value).join(','),
whitelist: [],
dropdown: {
classname: 'custom-tagify-look card',
maxItems: 5,
}
})
let controller;
tagify.on('input', onInput)
function onInput(e) {
const value = e.detail.value
tagify.whitelist = null
controller && controller.abort()
controller = new AbortController()
tagify.loading(true).dropdown.hide()
fetch(`${location.protocol}//${location.host}/tags?name=${value}`, { signal: controller.signal })
.then(RES => RES.json())
.then(function (newWhitelist) {
tagify.whitelist = newWhitelist.data
tagify.loading(false).dropdown.show(value)
})
}
});
}
app/javascript/packs/application.scss
.custom-tagify-lookでBulmaのタグのスタイルと同じになるようにしています。
タグが日本語の場合、--tag-inset-shadow-sizeの値がデフォルトだとタグの真ん中だけ色がなくなるので値を変更しています。
@import "~bulma/css/bulma.min";
@import "@yaireo/tagify/src/tagify.scss";
.height-unset {
height: unset;
}
.custom-tagify-look {
--tag-bg: #f5f5f5;
--tags-focus-border-color: #485fc7;
--tag-text-color: #363636;
--tag-inset-shadow-size: 1.35em;
}
.custom-tagify-look > div {
border: unset;
}
モデルスペック
記事に登録できるタグの数とタグの名前の長さと使える文字のバリデーション、タグの名前のアルファベットが小文字で保存されているかをテストしています。
require 'rails_helper'
RSpec.describe Article, type: :model do
describe 'validate_tag' do
context 'when article has no tags' do
it 'is valid' do
article = described_class.new(
title: 'title',
body: 'body',
tag_list: ''
)
expect(article).to be_valid
end
end
context 'when article has 5 tags' do
it 'is valid' do
article = described_class.new(
title: 'title',
body: 'body',
tag_list: 'ruby, php, python, go, c'
)
expect(article).to be_valid
end
end
context 'when article has 6 tags' do
it 'is invalid' do
article = described_class.new(
title: 'title',
body: 'body',
tag_list: 'ruby, php, python, go, c, java'
)
expect(article).to be_invalid
expect(article.errors).to be_of_kind(:tag_list, :too_many_tags)
end
end
context 'when tag name length <= 30' do
it 'is valid' do
article = described_class.new(
title: 'title',
body: 'body',
tag_list: 'a' * 30
)
expect(article).to be_valid
end
end
context 'when tag name length > 30' do
it 'is invalid' do
article = described_class.new(
title: 'title',
body: 'body',
tag_list: 'a' * 31
)
expect(article).to be_invalid
expect(article.errors[:tag_list]).to include 'は30文字以内で入力してください'
end
end
context 'when tag name has valid words' do
let(:valid_tag_names) { %w[ひらがな ゔぁ ヴァ カタカナー 漢字 alphabet ALPHABET 12345] }
it 'is valid' do
valid_tag_names.each do |tag_name|
article = described_class.new(
title: 'title',
body: 'body',
tag_list: tag_name
)
expect(article).to be_valid
end
end
end
context 'when tag name has invalid words' do
let(:invalid_tag_names) { %w[カタカナ ー alphabet ALPHABET + / / * ? '] }
it 'is invalid' do
invalid_tag_names.each do |tag_name|
article = described_class.new(
title: 'title',
body: 'body',
tag_list: tag_name
)
expect(article).to be_invalid
expect(article.errors[:tag_list]).to include 'はひらがな、または全角のカタカナ、漢字、半角の英数字のみが使用できます'
end
end
end
end
context 'when tag_list has duplicated words' do
it 'dose not have duplicated tags' do
article = described_class.create(
title: 'title',
body: 'body',
tag_list: 'ruby, ruby, Ruby'
)
expect(article.tags.map(&:name)).to eq %w[ruby]
end
end
end
リクエストスペック
タグ入力時のサジェスト機能のリクエストのテストをしています。
require 'rails_helper'
RSpec.describe 'Tags', type: :request do
describe 'GET /tags?name=:name' do
before do
create :tag, name: 'ruby'
create :tag, name: 'rubyonrails'
create :tag, name: 'rails'
end
it 'returns a 200 response' do
get tags_path(name: 'ruby')
expect(response.status).to eq 200
end
it 'returns correct tag count' do
get tags_path(name: 'ruby')
json = JSON.parse(response.body)
expect(json['data'].length).to eq 2
end
end
end
システムスペック
タグが登録できるかをテストしています。
require 'rails_helper'
RSpec.describe 'Articles', type: :system do
context 'with no tags' do
it 'creates a new article', js: true do
visit new_article_path
fill_in 'article[title]', with: 'title'
fill_in 'article[body]', with: 'body'
expect do
click_button '登録する'
end.to change(Article, :count).by(1)
expect(page).to have_content 'Article was successfully created.'
end
end
context 'with 5 tags' do
it 'creates a new article', js: true do
visit new_article_path
fill_in 'article[title]', with: 'title'
fill_in 'article[body]', with: 'body'
fill_in 'article[tag_list]', with: 'ruby, php, python, go, c', visible: false
expect do
click_button '登録する'
end.to change(Article, :count).by(1)
expect(page).to have_content 'Article was successfully created.'
end
end
context 'with 6 tags' do
it 'cannot create a new article', js: true do
visit new_article_path
fill_in 'article[title]', with: 'title'
fill_in 'article[body]', with: 'body'
fill_in 'article[tag_list]', with: 'ruby, php, python, go, c, java', visible: false
expect do
click_button '登録する'
end.to change(Article, :count).by(0)
expect(page).to have_content 'タグは5つ以下にしてください'
end
end
end