ActsAsTaggableOnとTagifyを用いたタグ機能のサンプルコード（バリデーション、サジェスト機能、URLにタグ名、テストコード）

## 概要
RailsでActsAsTaggableOnとTagifyを用いたタグ機能のサンプルコードです。
記事にタグを登録する機能とタグを入力時のサジェスト機能があるシンプルなアプリになっています。
バックエンドの処理を簡単に実装するためにActsAsTaggableOnを、見た目を整えるためにTagifyとBulmaを使用しています。

タグの詳細ページではURLのパラメーターにタグの名前を使用しています。
記事に登録できるタグの数とタグの名前の長さや使える文字のバリデーションやRSpecでバリデーションのモデルスペックやサジェスト機能のリクエストスペック、タグ登録のシステムスペックも書いてあります。
バリデーションはForemを参考にしました。

https://github.com/forem/forem

## GitHubリポジトリ
https://github.com/kwtuku/tag-sandbox

## デプロイ済みのアプリ
https://tags-sandbox-29112.herokuapp.com/

## バージョン
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図
![er_diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/990603/aaa917ac-44cd-1ed6-303f-cfc63509ac0d.png)

## 準備
下記のコマンドで記事に関するコードを作成しました。

```bash:ターミナル
$ rails g scaffold articles title:string body:text
```

ActsAsTaggableOnやTagify、Bulmaはドキュメントに従ってインストールしました。
TagifyとBulmaはNode moduleとしてインストールしています。

https://github.com/mbleigh/acts-as-taggable-on#installation

https://github.com/yairEO/tagify#installation

https://bulma.io/documentation/overview/start/

## ルーティング
タグのURLのパラメーターには名前が使用されるようになっています。

```ruby:config/routes.rb
Rails.application.routes.draw do
  root to: 'articles#index'
  resources :articles
  resources :tags, only: %i[index show], param: :name
end
```

## ActsAsTaggableOnの設定
アルファベットが小文字で保存されるようにしています。

```ruby:config/initializers/acts_as_taggable_on.rb
ActsAsTaggableOn.force_lowercase = true
```

## コントローラー
### articles_controller.rb
privateメソッド内のarticle_paramsにtag_listを追加しています。

```ruby:app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  .
  .
  .
  private
  .
  .
  .
  def article_params
    params.require(:article).permit(:title, :body, :tag_list)
  end
end
```

### tags_controller.rb
tags_controller.rbのindexアクションはタグ入力時のサジェスト機能のためのアクションです。

```ruby:app/controllers/tags_controller.rb
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メソッドを呼び出してタグの名前に関するバリデーションを行っています。

```ruby:app/models/article.rb
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文字以内で、ひらがなと全角のカタカナ、漢字、半角の英数字のみが使用できるようなバリデーションを定義しています。

```ruby:app/models/tag.rb
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のスタイルを調整しています。

```erb:app/views/articles/_form.html.erb
<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という部分テンプレートにしています。

```erb:app/views/articles/index.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のスタイルを適用しています。

```erb:app/views/articles/_articles.html.erb
<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と同じように追加しています。

```erb:app/views/articles/show.html.erb
.
.
.
<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

```erb: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を読み込んでいます。

```JavaScript:app/javascript/packs/application.js
.
.
.
ActiveStorage.start()

import handleTag from '../src/handleTag'
handleTag();
```

### app/javascript/src/handleTag.js
アウトプットのフォーマットをActsAsTaggableOnに合うようにコンマ区切りに変更しています。
.cardはBulmaのスタイル用のクラスです。
fetch()でtags#indexにリクエストを送信してサジェスト機能を実現しています。

```JavaScript:app/javascript/src/handleTag.js
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の値がデフォルトだとタグの真ん中だけ色がなくなるので値を変更しています。

```scss:app/javascript/packs/application.scss
@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;
}
```

## モデルスペック
記事に登録できるタグの数とタグの名前の長さと使える文字のバリデーション、タグの名前のアルファベットが小文字で保存されているかをテストしています。

```ruby:spec/models/article_spec.rb
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[ｶﾀｶﾅ ｰ ａｌｐｈａｂｅｔ ＡＬＰＨＡＢＥＴ + / ／ * ? '] }

      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
```

## リクエストスペック
タグ入力時のサジェスト機能のリクエストのテストをしています。

```ruby:spec/requests/tags_spec.rb
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
```
## システムスペック
タグが登録できるかをテストしています。

```ruby:spec/system/articles_spec.rb
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
```