【Rails5】オートコンプリート付きのタグ入力 〜バッジ風〜
記事や写真などにタグを付けるというのは昨今よく見るシーンです。この記事ではタグの入力フォームに自動予測機能を加えてタグ入力時にバッジ風な見せ方にする方法をご紹介します。
自分で書いていて全くピンと来ない説明だったので完成図を添えておきます。
これ↓を作ろうと思います:
1.準備
1-1. jQuery pluginのダウンロード
以下のページからプラグインをダウンロードします。
jQuery autocompleteをダウンロードする際に必要な部分のみをダウンロードするにはダウンロードビルダー ページのComponents > Toggle allのチェックを外し
Widgets > Autocompleteにチェックを入れると自動的に必要なものだけにチェックが入ります。
ページ最下部にある「Download」ボタンを押し、プラグインをダウンロードしてください。
同様に、jQuery-Tags-Inputページの「Download ZIP」ボタンを押して、プラグインをダウンロードしてください。
ZIPファイルをダウンロードし終えたら、vendorディレクトリにassetsディレクトリを作成し、その中に
- css
- javascripts
ディレクトリを作り、minifiedされたcssファイルとjavascriptファイルを入れてください。
1 2 |
jquery-ui.min.css jquery.tagsinput.min.css |
1 2 |
jquery-ui.min.js jquery.tagsinput.min.js |
アプリケーションがちゃんとファイルを読み込むようにapplication.jsとapplication.css(もしくはapplication.scss)に適切な処理を書き加えます。
1 2 3 4 5 6 7 8 9 |
//= require rails-ujs //= require activestorage //= require turbolinks //= require jquery3 //= require popper //= require bootstrap //= require jquery-ui.min //= require jquery.tagsinput.min //= require_tree . |
1 2 3 4 5 6 |
@import "bootstrap"; @import "./styles"; @import "font-awesome-sprockets"; @import "font-awesome"; @import "jquery-ui.min"; @import "jquery.tagsinput.min"; |
1-2. Modelの作成と設定
今回は記事にタグを付ける形にしようと思うのでPost Modelをscaffoldします。
1 |
rails g scaffold post title:text |
そしてタグはhas_many through でPostモデルに関連付けるのでTag ModelとTagging Modelを作ります。
1 |
rails g model tag name:text |
1 |
rails g model tagging tag:references post:references |
各種モデルを生成したら一旦db:migrateしましょう
1 |
rails db:migrate |
それではそれぞれの関係性などを設定していきましょう。
1 2 3 4 5 6 |
class Post < ApplicationRecord has_many :taggings, dependent: :destroy has_many :tags, through: :taggings accepts_nested_attributes_for :tags, :reject_if => proc { |att| att[:name].blank?} end |
1 2 3 4 |
class Tag < ApplicationRecord has_many :taggings, dependent: :destroy has_many :posts, through: :taggings end |
Taggingモデルは何も追記する必要はありません。生成した時にreferencesをつけたので以下のようになっていると思います。
1 2 3 4 |
class Tagging < ApplicationRecord belongs_to :tag belongs_to :post end |
タグは記事と一緒に生成できるようにPost.rbにaccepts_nested_attributes_forをつけています。空のタグが生成されるのを回避するためにreject_ifでnameプロパティが空の場合はrejectするように書いています。
2.フォームの用意
2-1. 記事投稿と一緒にタグの生成
まずは記事を投稿するフォームと一緒にタグ付けようの入力ボックスを作ります。
ヘッダーのコードなどはBootstrap Example から一部拝借しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
<!DOCTYPE html> <html> <head> <title>Orangeplate</title> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> </head> <body> <header> <!-- Fixed navbar --> <nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark"> <a class="navbar-brand" href="#">Fixed navbar</a> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarCollapse"> <ul class="navbar-nav mr-auto"> <li class="nav-item active"> <a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a> </li> <li class="nav-item"> <a class="nav-link" href="#">Link</a> </li> <li class="nav-item"> <a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Disabled</a> </li> </ul> <form class="form-inline mt-2 mt-md-0"> <input class="form-control mr-sm-2" type="text" placeholder="Search" aria-label="Search"> <button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button> </form> </div> </nav> </header> <%= yield %> </body> </html> |
1 2 3 4 5 6 7 8 9 |
<!-- Begin page content --> <main role="main" class="flex-shrink-0"> <div class="container pt-5"> <h1 class="mt-5">記事を投稿する</h1> <%= render 'form', post: @post %> <%= link_to '戻る', posts_path %> </div> </main> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
<%= form_with(model: post, local: true) do |f| %> <% if post.errors.any? %> <div id="error_explanation"> <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2> <ul> <% post.errors.full_messages.each do |message| %> <li><%= message %></li> <% end %> </ul> </div> <% end %> <div class="form-group"> <%= f.label :title, "タイトル" %> <%= f.text_field :title, class:"form-control" %> </div> <div class="form-group"> <%= f.fields_for :tags do |tf| %> <%= tf.label :name, "タグ" %> <%= tf.text_field :name, id:"formTagInput", class:"form-control" %> <% end %> </div> <div class="form-group mb-5"> <%= f.submit "投稿", class:"btn btn-primary" %> </div> <% end %> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
<main role="main" class="flex-shrink-0"> <div class="container pt-5"> <% if notice %> <p id="notice" class="mt-3 alert alert-success"><%= notice %></p> <% end %> <h1 class="mt-5">記事一覧</h1> <table class="w-100" style="table-layout: fixed"> <thead> <tr> <th>タイトル</th> <th>タグ</th> <th colspan="3"></th> </tr> </thead> <tbody> <% @posts.each do |post| %> <tr> <td><%= post.title %></td> <td> <% post.tags.each do |tag| %> <div><%= tag.name %></div> <% end %> </td> <td> <div><%= link_to '表示', post %></div> <div><%= link_to '編集', edit_post_path(post) %></div> <div><%= link_to '削除', post, method: :delete, data: { confirm: '本当に削除しますか?' } %></div> </td> </tr> <% end %> </tbody> </table> <br> <%= link_to '記事を投稿する', new_post_path %> </div> </main> |
この状態で一度コンソールからrails sをして、localhost:3000/posts/newにアクセスしてみましょう。
タグの入力フォームが表示されていません。タグの入力フォームを表示させるにはTagモデルのオブジェクトをbuildしないといけません。
1 2 3 4 |
def new @post = Post.new @post.tags.build end |
もう一度localhost:3000/posts/newにアクセスしてみましょう。
今度は表示されました。ですがまだ終わりではありません。この状態でタイトルとタグを入力して投稿しても、タグだけ弾かれます。Tagも一緒に生成するにはPostのStrong Parameterにtags_attributeを追加しないといけません。
1 2 3 4 5 6 7 8 |
def post_params params.require(:post).permit( :title, tags_attributes: [ :name ] ) end |
これでPostと一緒にTagも生成されるようになりました。PostControllerのcreate actionとindex actionを少し修正して、記事を投稿してみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def create @post = Post.new(post_params) respond_to do |format| if @post.save format.html { redirect_to posts_url, notice: '新しく記事が投稿されました' } format.json { render :index, status: :created, location: posts_url } else format.html { render :new } format.json { render json: @post.errors, status: :unprocessable_entity } end end end |
1 2 3 |
def index @posts = Post.includes(tags: :posts).all end |
タグも一緒に生成されていますね。
2-2. 既存のタグの確認と関連付け
無事に生成されたのは良いのですが、ここでまた一つ不都合なことがあります。次に新しく記事を投稿する際にタグ入力フォームに「タグ①」と入力して記事を投稿すると、先ほど生成したタグとは別に二つ目の「タグ①」が生成されます。
同じタグ名でも毎回新しいタグが生成されるのは無駄ですし、特定のタグに関連している記事数を出したい場面もあるかもしれません。
理想としては、記事が保存される前に既存のタグがあるかどうかを判定し、ある場合は既存のタグを関連付けし、無い場合は新しくタグを生成することです。
Postモデルに以下を追加しましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class Post < ApplicationRecord has_many :taggings, dependent: :destroy has_many :tags, through: :taggings accepts_nested_attributes_for :tags, :reject_if => proc { |att| att[:name].blank?} before_save :find_or_create_tag private def find_or_create_tag self.tags = self.tags.map {|tag| Tag.find_or_create_by(name: tag.name.strip)} end end |
before_save コールバックを使い、Postモデルが保存される前に、タグの存在の有無を判定しています。
tag.name.stripはスペースを排除することによって、同じタグなのに空白によって別のタグとして新しく生成されるのを防ぐためです。
例)
「タグ①」
「タグ① 」
↑は同じタグなのに、スペースがあると別のタグとして生成されてしまう
それではちゃんと同じタグが使われているかどうかを確認するために、以下のファイルを少し修正して新しく記事を投稿して見ましょう。タグは前回入力したものと同じにしてください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
<main role="main" class="flex-shrink-0"> <div class="container pt-5"> <% if notice %> <p id="notice" class="mt-3 alert alert-success"><%= notice %></p> <% end %> <h1 class="mt-5">記事一覧</h1> <table class="w-100" style="table-layout: fixed"> <thead> <tr> <th>タイトル</th> <th>タグ</th> <th colspan="3"></th> </tr> </thead> <tbody> <% @posts.each do |post| %> <tr> <td><%= post.title %></td> <td> <% post.tags.each do |tag| %> <div><%= tag.name %></div> <div><%= tag.posts.size %></div> <!--タグに関連してる記事数を出しています--> <% end %> </td> <td> <div><%= link_to '表示', post %></div> <div><%= link_to '編集', edit_post_path(post) %></div> <div><%= link_to '削除', post, method: :delete, data: { confirm: '本当に削除しますか?' } %></div> </td> </tr> <% end %> </tbody> </table> <br> <%= link_to '記事を投稿する', new_post_path %> </div> </main> |
「タグ①」に関連しているPostの数が「2」なので、ちゃんと同じタグが使われていますね。本当に新しくタグが生成されていないかを確認したい場合はコンソールで「rails c」をした後に以下を試してください。
1 |
Tag.all |
Tagのレコードが一つだけ表示されていれば問題ありません。
3. オートコンプリート
3-1. オートコンプリートとバッジ風の表示
ここまで随分時間が掛かりましたがここからが本番です。タグの入力フォームに自動予測をつけて、タグの表示もバッジ風にします。
posts.js(私はcoffeescriptを書くこと断念した人間なのでjsに変えています)に以下を追加してください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
$(document).on('turbolinks:load',function(){ $('#formTagInput').tagsInput({ 'autocomplete_url': "/tags/autocomplete.json", 'autocomplete': { focus: function(event, ui){ $('#formTagInput_tag').val(ui.item.name); return false; }, select: function(event, ui) { $('#formTagInput').addTag(ui.item.name); return false; } }, 'height:': 'calc(2.25rem + 2px)', 'width': '100%', 'defaultText': '' }) if($("#formTagInput_tag").length){ $('#formTagInput_tag').data('ui-autocomplete')._renderItem = function(ul, item) { return $('<li class="w-100">').data('item.autocomplete', item).append('<a class="w-100 d-flex">' + item.name + '</a>') .appendTo(ul); } } }); |
この記事の冒頭で導入したjQuery-Tags-Inputプラグインを使い、タグの入力フォームにtagsInputします。
そうすると、タグの入力フォームに入力を行うと非同期的にautocomplete_urlで指定したURLにPOSTします。ですので、新しくactionを作りましょう。わざわざこれだけのためにtags_controllerを作りたく無いので、posts_controllerに「tagAutocomplete」actionを追加します(ちゃんとルーティングされていればどこに作っても問題ないので各々のご判断で追加してください)。
1 2 3 4 |
def tagAutocomplete @tags = Tag.all.where('name LIKE ?', "%#{params[:term]}%") render json: @tags.map{ |tag| {name:tag.name}}.to_json end |
1 2 3 4 |
Rails.application.routes.draw do get '/tags/autocomplete.json', to: 'posts#tagAutocomplete' resources :posts end |
params[:term]はautocomplete pluginの仕様でフォームに入力したタグが入っています。入力されたタグが既存のタグと部分的にも一致したらJSON形式でタグを返しています。
次に、返ってきたJSONをrenderします。
1 2 3 4 |
$('#formTagInput_tag').data('ui-autocomplete')._renderItem = function(ul, item) { return $('<li class="w-100">').data('item.autocomplete', item).append('<a class="w-100 d-flex">' + item.name + '</a>') .appendTo(ul); } |
(#formTagInput_tag)はjQuery-Tags-Inputプラグインが自動生成するinput要素です。このinput要素に自動予測候補をrenderしています。
それでは一度localhost:3000/posts/newにアクセスして見ましょう。
見た目が若干変わってますね。
右クリック > 「検証」をして要素の検証をして見ましょう。
元々のタグの入力フォーム(’#formTagInput’)に「display:none」が追加され、代わりに(#formTagInput_tagsinput) div 要素が追加されその中に(#formTagInput_tag) input要素がありますね。
試しに「タグ①」と入力して見ましょう。
「タ」と入力したあたりから予測候補に「タグ①」と出てきました。Enterを押すと、バッジ風の表示になります。
しかし再度自動生成される#formTagInput_tagになぜか.not_validクラスがついてしまい(空白として認識されてしまうからですかね?)さらにフォーカスが外れてしまうのでonAddTagオプションを使って .not_validクラスを除外しもう一度focusを合わせます。posts.jsに以下を追加してください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
$(document).on('turbolinks:load',function(){ $('#formTagInput').tagsInput({ 'autocomplete_url': "/tags/autocomplete.json", 'autocomplete': { focus: function(event, ui){ $('#formTagInput_tag').val(ui.item.name); return false; }, select: function(event, ui) { $('#formTagInput').addTag(ui.item.name); return false; } }, 'onAddTag': function(){ $('#formTagInput_tag').removeClass('not_valid') $('#formTagInput_tag').focus(); return false; }, 'height:': 'calc(2.25rem + 2px)', 'width': '100%', 'defaultText': '' }) if($("#formTagInput_tag").length){ $('#formTagInput_tag').data('ui-autocomplete')._renderItem = function(ul, item) { return $('<li class="w-100">').data('item.autocomplete', item).append('<a class="w-100 d-flex">' + item.name + '</a>') .appendTo(ul); } } }); |
3-2. 複数タグ
一見ここで完成のように思えますが、さらに不都合なことがあります。試しに「タグ①」と「タグ②」を入力して記事投稿して見ます。
そうすると
コンソールのParametersを見てみると、
1 |
"post"=>{"title"=>"タイトル③", "tags_attributes"=>{"0"=>{"name"=>"タグ①,タグ②"}}} |
と出ています。「”,”」で繋がれた一つの文字列としてControllerに渡されています。ですので、記事が保存される前に「”,”」でsplitして別々のタグとして関連付けを行わないといけません。Post.rbを下記のように修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
class Post < ApplicationRecord has_many :taggings, dependent: :destroy has_many :tags, through: :taggings accepts_nested_attributes_for :tags, :reject_if => proc { |att| att[:name].blank?} before_save :find_or_create_tag private def find_or_create_tag tag_array = [] self.tags.map{ |tag| tag.name.strip.split(",").each do |name| tag_array << name end } self.tags.destroy_all tag_array.each do |tag| self.tags << Tag.find_or_create_by(name: tag) end end end |
まず、空の配列 tag_arrayを作り、「”,”」でsplitした文字列を配列の中に挿入して行きます。そして一度buildしたtagオブジェクトをdestroyします。これを行わないと連結されたままのタグが生成されてしまいます。
最後に配列をeachで回して、改めてPostに関連付けています。もう一度「タグ①」と「タグ②」を入力して記事投稿して見ましょう。
今度はちゃんと別々のタグとして生成されました!
最終的なコードは以下になります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
class Post < ApplicationRecord has_many :taggings, dependent: :destroy has_many :tags, through: :taggings accepts_nested_attributes_for :tags, :reject_if => proc { |att| att[:name].blank?} before_save :find_or_create_tag private def find_or_create_tag tag_array = [] self.tags.map{ |tag| tag.name.strip.split(",").each do |name| tag_array << name end } self.tags.destroy_all tag_array.each do |tag| self.tags << Tag.find_or_create_by(name: tag) end end end |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
<%= form_with(model: post, local: true) do |f| %> <% if post.errors.any? %> <div id="error_explanation"> <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2> <ul> <% post.errors.full_messages.each do |message| %> <li><%= message %></li> <% end %> </ul> </div> <% end %> <div class="form-group"> <%= f.label :title, "タイトル" %> <%= f.text_field :title, class:"form-control" %> </div> <div class="form-group"> <%= f.fields_for :tags do |tf| %> <%= tf.label :name, "タグ" %> <%= tf.text_field :name, id:"formTagInput", class:"form-control" %> <% end %> </div> <div class="form-group mb-5"> <%= f.submit "投稿", class:"btn btn-primary" %> </div> <% end %> |
1 2 3 4 5 6 7 8 9 |
<!-- Begin page content --> <main role="main" class="flex-shrink-0"> <div class="container pt-5"> <h1 class="mt-5">記事を投稿する</h1> <%= render 'form', post: @post %> <%= link_to '戻る', posts_path %> </div> </main> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
<main role="main" class="flex-shrink-0"> <div class="container pt-5"> <% if notice %> <p id="notice" class="mt-3 alert alert-success"><%= notice %></p> <% end %> <h1 class="mt-5">記事一覧</h1> <table class="w-100" style="table-layout: fixed"> <thead> <tr> <th>タイトル</th> <th>タグ</th> <th colspan="3"></th> </tr> </thead> <tbody> <% @posts.each do |post| %> <tr> <td><%= post.title %></td> <td> <% post.tags.each do |tag| %> <div><%= tag.name %></div> <div><%= tag.posts.size %></div> <!--タグに関連してる記事数を出しています--> <% end %> </td> <td> <div><%= link_to '表示', post %></div> <div><%= link_to '編集', edit_post_path(post) %></div> <div><%= link_to '削除', post, method: :delete, data: { confirm: '本当に削除しますか?' } %></div> </td> </tr> <% end %> </tbody> </table> <br> <%= link_to '記事を投稿する', new_post_path %> </div> </main> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
class PostsController < ApplicationController before_action :set_post, only: [:show, :edit, :update, :destroy] # GET /posts # GET /posts.json def index @posts = Post.includes(tags: :posts).all end # GET /posts/1 # GET /posts/1.json def show end # GET /posts/new def new @post = Post.new @post.tags.build end # GET /posts/1/edit def edit end # POST /posts # POST /posts.json def create @post = Post.new(post_params) respond_to do |format| if @post.save format.html { redirect_to posts_url, notice: '新しく記事が投稿されました' } format.json { render :index, status: :created, location: posts_url } else format.html { render :new } format.json { render json: @post.errors, status: :unprocessable_entity } end end end # PATCH/PUT /posts/1 # PATCH/PUT /posts/1.json def update respond_to do |format| if @post.update(post_params) format.html { redirect_to @post, notice: 'Post was successfully updated.' } format.json { render :show, status: :ok, location: @post } else format.html { render :edit } format.json { render json: @post.errors, status: :unprocessable_entity } end end end # DELETE /posts/1 # DELETE /posts/1.json def destroy @post.destroy respond_to do |format| format.html { redirect_to posts_url, notice: 'Post was successfully destroyed.' } format.json { head :no_content } end end def tagAutocomplete @tags = Tag.all.where('name LIKE ?', "%#{params[:term]}%") render json: @tags.map{ |tag| {name:tag.name}}.to_json end private # Use callbacks to share common setup or constraints between actions. def set_post @post = Post.find(params[:id]) end # Never trust parameters from the scary internet, only allow the white list through. def post_params params.require(:post).permit( :title, tags_attributes: [ :name ] ) end end |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
$(document).on('turbolinks:load',function(){ $('#formTagInput').tagsInput({ 'autocomplete_url': "/tags/autocomplete.json", 'autocomplete': { focus: function(event, ui){ $('#formTagInput_tag').val(ui.item.name); return false; }, select: function(event, ui) { $('#formTagInput').addTag(ui.item.name); return false; } }, 'onAddTag': function(){ $('#formTagInput_tag').removeClass('not_valid') $('#formTagInput_tag').focus(); return false; }, 'height:': 'calc(2.25rem + 2px)', 'width': '100%', 'defaultText': '' }) if($("#formTagInput_tag").length){ $('#formTagInput_tag').data('ui-autocomplete')._renderItem = function(ul, item) { return $('<li class="w-100">').data('item.autocomplete', item).append('<a class="w-100 d-flex">' + item.name + '</a>') .appendTo(ul); } } }); |
おまけ:オートコンプリートの候補リストに関連記事数も表示する
ありえそうなシチュエーションとして、それぞれのタグに何個の記事が関連付けられているか知りたい場面があると思います。
今回はタグ入力時に出てくる自動予測の候補リストに関連記事数も表示します。
1 2 3 4 |
def tagAutocomplete @tags = Tag.includes(:posts).all.where('name LIKE ?', "%#{params[:term]}%") render json: @tags.map{ |tag| {name:tag.name, count: tag.posts.size}}.to_json end |
1 2 3 4 5 6 |
if($("#formTagInput_tag").length){ $('#formTagInput_tag').data('ui-autocomplete')._renderItem = function(ul, item) { return $('<li class="w-100">').data('item.autocomplete', item).append('<a class="w-100 d-flex">' + item.name + ' (' + item.count +') </a>') .appendTo(ul); } } |
以上、タグの入力フォームに自動予測機能を加えてタグ入力時にバッジ風な見せ方にする方法でした。