Rails + Vue.jsによる動的タグ付け機能

# はじめに

社会人1年目の問題意識・問題解決の習慣化と情報共有を補助するツールとして[意習(issue)](https://github.com/fumtas1k/issues_app)というアプリを作りました。日頃の業務の中で遭遇するイシューをアウトプット、新人同士で情報共有し、メンターがフォロー/サポートすることで問題意識と解決能力を養う、というアプリです。

タグ機能を作成するときに、`acts-as-taggable-on`による動的タグ付け(複数選択可)とUIに`vue-multiserect`を使用しました。**この組み合わせでの記事が見当たらなかった**ので、記載することとしました。
完成版は、下図の通りです。

![tags.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2493295/c97eb437-768b-7a3c-94bc-20c87c98b176.gif)

## 環境

- Ruby: 3.0.1
- Ruby on Rails: 6.0.3 
  + acts-as-taggable-on: 9.0.1
- Vue.js: 2.6.14
  + Vue-multiselect: 2.1.4
- jQuery: 3.5.1
- postgreSQL: 14.2


## :pushpin:前置き

### 注意点

現在vue.jsの最新版は3系ですが、使用しているライブラリ(vue-multiselect)が対応していないため(実際3系ではコードを変えても動きませんでした)、2系を使用しています。

### 説明しないこと

- yarnなどを用いたjQuery, Bootstrap, Vue.jsのインストール方法(CDNでの実装を説明します)。
- Vue.jsの細かな説明。
- Ransackでのタグの検索方法。

### 前提
:::note info
`User`モデル(なくてもOK)と`Issue`モデル(≒記事投稿のモデル)はすでに作成済み、`Issue`のテーブル及びCRUD機能も実装済みとします。
:::

![tags.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2493295/f9362ec1-52d0-26a4-5cd8-47dcbd5c23d5.jpeg)

## acts-as-taggable-on導入手順

`acts-as-taggable-on`は動的タグ付けが可能となるgemです。記事投稿と同時に新たなタグを生成し登録することも、既存のタグをつけることも可能です。
`acts-as-taggable-on`に関して詳しく知りたい方は、gemの[ReadMe](https://github.com/mbleigh/acts-as-taggable-on)や、[ 【Rails】タグ管理機能(acts-as-taggable-on使用)](https://qiita.com/manbolila/items/3f178b7698d877a29b12)をご確認下さい。


### acts-as-taggable-onのインストール

Gemfileに以下を追加。

```ruby:Gemfile
gem 'acts-as-taggable-on'
```

ターミナルにて以下を実行しインストール.

```:ターミナル
rails acts_as_taggable_on_engine:install:migrations
rails db:migrate
```
これにより、`tags`, `taggings`の2つのテーブルが作成されます。

::: note warn
注： MySQL使用の場合は、`migrate`前に以下を実行する必要があるようです。


```:ターミナル
rake acts_as_taggable_on_engine:tag_names:collate_bin
```

詳細は、gemの[ReadMe](https://github.com/mbleigh/acts-as-taggable-on)をご確認下さい。ただ、うまくいかない場合があるようなので、MySQL使用の方は、こちらの記事が参考になるかもしれません。 ->[ 【Rails】タグ管理機能(acts-as-taggable-on使用)](https://qiita.com/manbolila/items/3f178b7698d877a29b12)
:::

### モデルの設定

タグをつけたいモデル(`Issue`)に以下を記載。タグの名前を`tag`とする場合の設定です。タグの名前を`skill`としたければ、`tags`を`skills`に置き換えて下さい。

```ruby:issue.rb
acts_as_taggable_on :tags
```

この設定により、`Tagging`モデルの`taggable_id`と`Issue`モデルの`id`が繋がります。
`User`モデルと`tag`を結びつける必要はなかったため、今回、`User`モデルと`Tagging`モデルの連携は作成しません。

![tags.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2493295/b79ad837-1c2a-8882-38b3-6db70571d09f.jpeg)

### コントローラーの設定

タグ付けするモデルコントローラーのストロングパラメーターに`:tag_list`を追加します。

```ruby:issues_controller.rb
private
def issue_params
  params.require(:issue).permit(..., :tag_list)
end
```

### ビューの設定

後ほど、**vue-multiselect**の設定に置き換えます。一旦、動作するかの確認のため、以下を追記します。
`Issue`の新規投稿、編集はパーシャルの`_form.html.erb`を使用している設定です。

```html:_form.html.erb
<%= form_with(model: issue, class: "form", local: true) do |form| %>
  <%# 省略 %>
  <div class="form-group">
    <%= form.label :tag_list %>
    <%= text_field_tag "issue[tag_list]", issue.tag_list.join(","), class: "form-control" %>
  </div>
 <%# 省略 %>
<% end %>
```

`Issue`のshowビューに以下を追加。

```html:show.html.erb
<%# 省略 %>
<div class="issue-tag-container">
  <% @issue.tag_list.each do |tag_name| %>
    <%= tag_name %><br>
  <% end %>
</div>
<%# 省略 %>
```

以下のように、テキスト入力で登録できていれば成功です。
![tag_test.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2493295/fbea217a-8865-caa0-ae8d-a6a7ea4a5738.gif)

実際に、コントローラーに送られるパラメーターは、以下のように　","　区切りの文字列なります。

```ruby
<ActionController::Parameters {..., "issue"=>
<ActionController::Parameters {..., "tag_list"=>"DI,抗がん剤,こんにちは！", ...} ...>, ...>
```

### acts-as-taggable-onのメソッド紹介

使用したメソッドを一部紹介します。タグ付けしたモデルを`Issue`, タグを`tag`として記載します。
| メソッド | 説明 |
| :- | :- |
| Issue.tagged_with(name) | nameのタグ付けがされた全Issueを返す |
| Issue.tags_on(:tags) | そのモデルに登録されている全タグを返す　 |
| Issue#tags_on(:tags) | そのインスタンスに登録されている全タグを返す　 |
| Issue#tag_list | そのインスタンスに登録されている全タグのnameを配列で返す |
| Issue#tag_list=(value) | そのインスタンスのタグにvalueを登録・上書き(登録・上書きにはsaveも必要)。valueはタグのnameを配列orカンマ区切りの文字列で渡す。例: ["east", "south"]　or "east, south" |
|ActsAsTaggableOn::Tag.most_used(value=20) | 登録数が多い順でタグを返す。デフォルトは20個。 |
| ActsAsTaggableOn::Tag#taggings_count | そのタグの登録数を返す |



## vue-multiselect導入手順

[vue-multiselect](https://vue-multiselect.js.org/)は、見た目が良いセレクトボックスを実装できるVueのライブラリです。ドロップダウンで、単一選択、複数選択、タグ付け等ができます。詳細は、リンクを参照して下さい。
ただ、リンク先の記述ではうまく動作しなかったため、以下に記載するコードはリンク先とは異なります。

### Vueのインストール(CDNの設定)

Vue.js, vue-multiselect, jQueryのCDNを設定し使用できるようにします。
jQueryをすでにインストール済みの場合は、jQueryの部分は削除して下さい。

```html:application.html.erb
<head>
  <%# 省略 %>
  <%# jQueryをインストールしている場合は最初の記述は不要 %>
  <script src="https://code.jquery.com/jquery-3.5.1.js" integrity="sha256-QWo7LDvxbWT2tbbQ97B53yJnYU3WhH/C8ycbRAkjPDc=" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14" defer></script>
  <script src="https://unpkg.com/vue-multiselect@2.1.4" defer></script>
  <link rel="stylesheet" href="https://unpkg.com/vue-multiselect@2.1.4/dist/vue-multiselect.min.css">
  <%# 省略 %>
</head>
```

### formの記述変更

`Issue`の新規投稿、編集はパーシャルの`_form.html.erb`を使用している設定です。
`_form.html.erb`に先ほど動作確認用に記述したコードを削除し、以下のコードに書き換えます。

```html:_form.html.erb
<%= form_with(model: issue, class: "form", local: true) do |form| %>
  <%# 省略 %>
  <%= content_tag(:div, id:"app", data:{tag_list: Issue.tags_on(:tags).pluck(:name), my_tag_list: issue.tag_list}) do %>
    <multiselect v-model="myTagList" tag-placeholder="Add this as new tag" placeholder="Search or add a tag"  :options="tagList" :multiple="true" :taggable="true" @tag="addTag" id="vue-tag-input"></multiselect>
    <pre class="language-json" hidden="hidden">
      <input type="text" name="issue[tag_list]" id="issue_tag_list" v-model="myTagList">
    </pre>
  <% end %>
  <%# 省略 %>
<% end %>
```

#### 解説

##### 1. Rails -> Vueへのデータ受け渡し

Vue.jsで処理する範囲の目印として`id: "app"`とします。
RailsからVue.jsへ、既存のタグデータ、および、編集時には、その記事に登録されているタグデータの受け渡しが必要です。
今回は、html内にデータを記述し、それをjavascriptで取得する方法で実装しました。`data:`の`{ }`内の記述が受け渡しデータになります。

```ruby
# モデル.tags_on(:タグ名)でそのモデルに紐づく全タグを取得できます。
# pluck(:name)でタグを配列で取得します。
tag_list: Issue.tags_on(:tags).pluck(:name)

# モデルのインスタンス.tag_listでそのモデルインスタンスに紐づくタグのnameを配列で取得できます。
my_tag_list: issue.tag_list
```

##### 2. Vue -> Railsへのデータ受け渡し

記事投稿、編集画面で操作されたタグデータを今度はRails側に渡さなければなりません。
`<pre>...</pre>`の中がデータ受け渡し用の記述となります。`hidden`を設定することで、画面には現れないようにしています。

```html
<%# 所謂、Railsの "hidden_field" の状態 #>
<pre class="language-json" hidden="hidden">
  <input type="text" name="issue[tag_list]" id="issue_tag_list" v-model="value">
</pre>
```

##### 3. Vue.jsの説明

ディレクティブ(接頭辞v-が付いたVue.jsの特別な属性)などを少しだけ紹介します。詳細は、[公式ドキュメント](https://jp.vuejs.org/v2/guide/)をご参照下さい。Vue.jsをそれなりに知っている方は、読み飛ばすことをお勧めします。

| ディレクティブ | 説明 |
| - | - |
| v-model | 入力イベントで要素を自動的に更新します。`v-model="myTagList"`とすることで、`myTagList`のデータを表示し、入力により変化したときには`myTagList`を更新します。 |
| @ | インスタンス変数ではありません。`v-on:`の省略記法です。イベントの発火時にjavascriptのメソッドを実行します。`@tag="addTag"`は`tag`イベント(新規のタグ作成)時に`addTag`メソッド(`tagList`, `myTagList`にタグ追加)が実行されます。 |
| : | `v-bind:`の省略記法です。 属性を動的に設定できます。 `:options="tagList"`は動的に変化する`tagList`の値を渡しています。 |


### javascript(jQuery/Vue.js)設定

#### tags.jsファイル新規作成
`app/javascript/packs`に、`tags.js`ファイルを作成し、以下を記述します。
javascriptは行末にセミコロンを入れますが、Vueの公式ドキュメントではセミコロンが省略されています。また、以下のVue.jsのコードはVue3系によせた記述にしています。

```javascript:tags.js
// turbolinkによりVue.jsがうまく動作しないことがあるため$(function(){});ではなく以下の記述
$(document).on('turbolinks:load', function() {
  // id="app"のタグにあるdata-tag-list, data-my-tag-listの情報をjQueryで取得しています。
  // _form.html.erbでは、"tag_list"とアンダースコアで記載していますが、htmlではハイフン表示になります。
  const tagList = $("#app").data("tag-list");
  const myTagList = $("#app").data("my-tag-list");

  // Vueコンポーネントのmultiselectを使用
  Vue.component('multiselect', window.VueMultiselect.default)
  new Vue({
    el: "#app",
    data() {
      return {
        // tagListとmyTagListの値をVue側の変数に代入し初期値(配列)にしています。
        // form側でv-modelを設定しているのでタグが追加・削除されるとmyTagListも追加・削除されます。
        tagList: tagList,
        myTagList: myTagList,
      }
    },
    methods: {
      // 新規のタグを、"tagList"及び"myTagList"に追加するメソッドを定義しています。
      // このメソッドがなくても、既存のタグは登録できます。新規のタグを生成したい場合は必要となります。
      addTag(newTag) {
        this.tagList.push(newTag)
        this.myTagList.push(newTag)
      },
    },
  })
});
```

#### application.jsにtags.jsファイルをインポート

`app/javascript/packs/application.js`に以下を追記し、`tags.js`が読み込まれるよう設定。

```javascript:application.js
// 省略
import "./tags.js";
```

### showビューの変更

`Issue`のshowビューを変更。先ほど記述したコードを削除し、以下に置き換え。


```html:show.html.erb
<%# 省略 %>
<div class="issue-tag-container">
  <% @issue.tag_list.each do |tag_name| %>
    <%= content_tag(:span, tag_name, class: "tag") %>
  <% end %>
</div>
<%# 省略 %>
```

cssを追加。
```css:aplication.css
.tag {
  display: inline-block;
  font-size: 12px;
  padding: 2px 10px;
  border-radius: 5px;
  margin-right: 5px;
  background-color: #41b883;
  color: #fff;
}
```

## 完成

以下のような動作になっていると思います。
新規作成した場合にshowビューに表示されているかまで確認して下さい。

![tags.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2493295/c97eb437-768b-7a3c-94bc-20c87c98b176.gif)


## おまけ：vue-multiselectに対するsystem spec

system specでは、入力フォームでのタグ入力は以下のコードで実装できます。
複数のタグを選択・登録したい場合は、その数だけ記述して下さい。

```ruby:issue_spec.rb
# multiselectタグのidを"vue-tag-input"にしています。
fill_in("vue-tag-input", with: "タグのname", visible: false).send_keys :return
```

## :green_book:参考資料

### acts-as-taggable-on

- [acts-as-taggable-on(GitHub)](https://github.com/mbleigh/acts-as-taggable-on)
- [【Rails】タグ管理機能(acts-as-taggable-on使用)(Qiita:更新日2020-09-16)](https://qiita.com/manbolila/items/3f178b7698d877a29b12)

### vue-multiselect

- [Vue.js 2公式ドキュメント](https://jp.vuejs.org/v2/guide/)
- [Vue-multiselect(2.1.4)](https://vue-multiselect.js.org/)
- [【Vue.js】Vue-multiselectについて解説(Code Database:更新日2020-05-03)](https://code-database.com/knowledges/63)


### vue-multiselectのrspec

- [RSpec のsystem spec 実行時にselenium でEnter キーの押下をシュミレートする(Qiita:更新日2021-04-29)](https://qiita.com/karlley/items/3d03563b74d05dccadea)
- [RSpec Capybara 非表示な要素のテストにハマる(HatenaBlog:更新日2019-10-08)](https://chaika.hatenablog.com/entry/2019/10/08/083000)