インクリメンタルサーチ
投稿の検索をインクリメンタルサーチで検索できるようにする。
インクリメンタルサーチを実装して以下の画像のように文字が入力されるごとに、検索をかけ結果を表示するように実装する。
事前準備
Pictweetの投稿検索をインクリメンタルサーチで行えるように準備を行う。
必ず以下から新しいインクリメンタルサーチ実装用のPictweetをgit clone
をしてから、インクリメンタルサーチを実装。
実装用に、新しいPictweetをgit cloneする
1 2 3 4 5 6 |
$ cd ~/projects
$ git clone https://github.com/exp-drill/search_pictweet.git
$ cd search_pictweet
$ bundle install
$ rails db:create
$ rails db:migrate
|
bundle installでエラーが生じた場合は以下のコマンドを実行し、再度bundle installを実行する
1 |
$ bundle config --global build.mysql2 --with-opt-dir="$(brew --prefix openssl)"
|
インデックスでデータの検索を高速する
tweetsテーブルのtextカラムにインデックスを貼ることで、データの検索を高速化する。
textカラムに対するインデックスを設定
まずは、tweetsテーブルに対してインデックスを貼るためのマイグレーションファイルを作成する。
1 |
$ rails g migration AddIndexToTweets
|
作成したマイグレーションファイルを編集してtextカラムにインデックスを貼る。
1 2 3 4 5 |
class AddIndexToTweets < ActiveRecord::Migration
def change
add_index :tweets, :text, length: 32
end
end
|
記述ができたらターミナルでマイグレーションを実行。
問題なく実行できたtweetsテーブルのtextカラムに対してインデックスが設定できている。
application.jsの記述を確認
Pictweetにおけるコメント投稿の非同期通信化を行ったときと同様に、確認する。
インクリメンタルサーチ実装のステップ
- ルーティングなどAPI側の準備
- テキストフィールドを作成
- テキストフィールドが入力されるたびにイベントが発火するようにする
- イベント時に非同期通信できるようにする
- 非同期通信の結果を得て、HTMLを作成
- エラー時の処理を行う
1. ルーティングなどAPI側の準備
アクションの中でHTMLとJSONなどのフォーマット毎に条件分岐する記述を追加する。
フォーマット毎に処理を分けるには、respond_to
を使用する。
respond_toを利用してフォーマット毎に処理を分ける
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class TweetsController < ApplicationController
before_action :set_tweet, except: [:index, :new, :create, :search]
before_action :move_to_index, except: [:index, :show, :search]
def index
@tweets = Tweet.includes(:user).order("created_at DESC").page(params[:page]).per(5)
end
# 中略
def search
@tweets = Tweet.search(params[:keyword])
respond_to do |format|
format.html
format.json
end
end
# 以下省略
|
投稿情報を取得したら、jbuilderを使ってJavaScript側に返す。
検索結果は、複数の投稿情報を表示させる。
そのため、複数の投稿情報が格納された配列を返すようなjbuilderの記述にする必要がある。
「search.json.jbuilder」というファイルを以下のディレクトリに作成
1 2 3 4 5 6 7 8 |
JSON形式のデータを配列で返したい場合は、上記のようにarray!
を使用。
jbuilder:array! メソッド
jbuilderという拡張子を持つテンプレートでは、JSONという名前のJbuilderオブジェクトが自動的に利用できるようになる。
Jbuilderオブジェクトは、JSON形式に返すための便利なメソッドがたくさん用意されており、配列で返したい場合はarray!を使用する。
array!を使用することで以下のように、JavaScript側に配列で値を送ることが可能。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
[{
id: 1,
image: "https://~.jpg",
nickname: "やべ",
text: "プログラミングの勉強中",
user_id: 1,
user_sign_in:
{created_at: "2019-10-08T01:47:37.000Z",
email: "aaa@gmail.com",
id: 1,
nickname: "やべ",
updated_at: "2019-10-08T01:47:37.000Z"}
}]
|
jbuilderを使用するとより少ない記述でJSON形式のデータを作ることができる。
2. テキストフィールドを作成
今回はすでに検索を入力するためのテキストフィールドを作成してあるので、こちらの作業は必要ない。
3. テキストフィールドが入力されるたびにイベントが発火するようにする
テキストフィールドに文字が入力されるたびにイベントが呼び出されるように実装する。
文字打ち込み終わったら、つまりキーを離したら処理をさせたいときはkeyupメソッド
を使用する。keyupメソッドを使って文字を入力する度にイベントが発火するか確かめてみる。
search.jsというファイルを以下のディレクトリに作成
- app
- assets
- javascripts
- search.js
- javascripts
- assets
1 2 3 4 5 |
$(function() {
$(".search-input").on("keyup", function() {
var input = $(".search-input").val();
});
});
|
上記のコードにおいて
今回の実装で使用するテキストフィールドは以下の部分。
1 2 3 4 |
<%= form_with(url: search_tweets_path, local: true, method: :get, class: "search-form") do |form| %>
<%= form.text_field :keyword, placeholder: "投稿を検索する", class: "search-input" %>
<%= form.submit "検索", class: "search-btn" %>
<% end %>
|
テキストフィールドのclass名はsearch-input
である。
よって、
1 2 3 |
$(".search-input").on("keyup", function() {
var input = $(".search-input").val();
});
|
の記述は、
「クラス名が".search-input”の部分のテキストフィールドがkeyupしたら、テキストフィールドの文字を取得して変数inputに代入する」
ということを表している。
フォームの値を取得するときはval()
を使う。
それでは、console.log()
を使い、フォームの値を確認。
1 2 3 4 5 6 |
$(function() {
$(".search-input").on("keyup", function() {
var input = $(".search-input").val();
console.log(input);
});
});
|
編集ができたら、検索の画面でコンソールを開き、フォームに入力した値が出力されるか確認。
コンソールに入力した値が出力されていれば成功。
4. イベント時に非同期通信できるようにする
キーが入力される度に非同期通信で投稿を検索できるように実装する。
1で設定したルーティングにアクセスして、コントローラーで該当する投稿内容を検索。
1 2 3 4 5 6 7 8 9 10 11 |
Ajax通信を実現するためには、上記のように$.ajaxメソッド
を使用する。
上記のコードは以下のような意味になる。
HTTPメソッドはGETで、/tweets/searchのURLに{ keyword: input }を送信。サーバーから値を返す際は、JSONになる。
rails routesで確かめれば明らかだが、上記のリクエストによって、tweets_controller.rbのsearchアクションが動く。
ここで、searchアクションのコードを確認。
1 2 3 4 5 6 7 |
$.ajaxのdataTypeでJSONを指定しているので、サーバーはJSON形式で値を返す。
普段のレスポンス(html形式のレスポンス)ではtweets_controller.rbのsearchアクションが実行されたら、
app/views/tweets/search.html.erb
が読まれるが、
JSON形式の場は、app/views/tweets/search.json.jbuilder
が読まれる。
うまくいっている場合は、該当する投稿情報はjbuilderによってJSONに変換されてJavaScriptのファイルに返されるはず。
5. 非同期通信の結果を得て、HTMLを作成
非同期通信の結果をdone
の関数の引数から受取り、ビューに追加するためのHTMLを作成する必要がある。
まずは、doneの中身でどんな値を取得をできるかデバッグする。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
サーバーエラーが起きていないときは、jbuilderからdoneの関数に投稿情報が返ってくる。
今回jbuilderでarray!
を使用しているため、配列型で情報が返ってきていて、その情報がtweetsに代入されている。
console.log()
でtweetsの中身をブラウザで確認すると、以下の画像のように配列でjbuilderからの情報を受け取れていることが確認できる。
tweetsの中身が確認できたら、console.log()
は削除して、doneの関数の中身を記述する。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
$(function() {
$(".search-input").on("keyup", function() {
var input = $(".search-input").val();
$.ajax({
type: 'GET',
url: '/tweets/search',
data: { keyword: input },
dataType: 'json'
})
.done(function(tweets) {
$(".contents.row").empty();
if (tweets.length !== 0) {
tweets.forEach(function(tweet){
appendTweet(tweet);
});
}
else {
appendErrMsgToHTML("一致するツイートがありません");
}
})
});
});
|
上記のコードについて
1 2 3 |
.done(function(tweets) {
$(".contents.row").empty();
})
|
インクリメンタルサーチでは、検索をする直前に投稿情報のリストを削除してあげる必要がある。
上の画像の部分を表示するビューを確認する。
1 2 3 4 5 6 |
上記コードの検索結果欄で出力されている投稿情報をemptyメソッド
を使用して削除。
empty メソッド
指定したDOM要素の子要素のみ
を削除するメソッド。
指定したDOM要素自体を削除するremoveメソッドとは異なるので注意。
投稿情報をすべて削除したいので、<div class="contents row">
の要素を取得すればいい。
class名は、.contents.row
とする。
1 |
$(".contents.row").empty();
|
よって、上記のコードで投稿の情報を削除できる。
次に、jbuilderから送られてきた配列の情報によって、場合分けをし関数を呼び出す。
1 2 3 4 5 6 7 8 9 10 11 |
appendTweetとappendErrMsgToHTMLは後で定義する。
これらの関数は、jbuilderから得られた値を投稿情報のリストに追加するものである。
tweetsが空ではない場合(tweets.length !== 0)
forEachメソッド
を用いて、tweetsの中身の数だけappendTweet関数を呼び出す。
forEach メソッド
forEachは、与えられた関数を配列に含まれる各要素に対して一度ずつ呼び出す。
tweetsが空の場合
”一致するツイートがありません”という引数を与え、appendErrMsgToHTML関数を呼び出す。
tweetsに投稿の情報が入っている場合のappendTweet関数を定義する。
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 |
$(function() {
var search_list = $(".contents.row");
function appendTweet(tweet) {
if(tweet.user_sign_in && tweet.user_sign_in.id == tweet.user_id){
var current_user = `<li>
<a href="/tweets/${tweet.id}/edit" data-method="get" >編集</a>
</li>
<li>
<a href="/tweets/${tweet.id}" data-method="delete" >削除</a>
</li>`
} else {
var current_user = ""
}
var html = `<div class="content_post" style="background-image: url(${tweet.image});">
<div class="more">
<span><img src="/assets/arrow_top.png"></span>
<ul class="more_list">
<li>
<a href="/tweets/${tweet.id}" data-method="get" >詳細</a>
</li>
${current_user}
</ul>
</div>
<p>${tweet.text}</p><br>
<span class="name">
<a href="/users/${tweet.user_id}">
<span>投稿者</span>${tweet.nickname}
</a>
</span>
</div>`
search_list.append(html);
}
$(".search-input").on("keyup", function() {
var input = $(".search-input").val();
$.ajax({
type: 'GET',
url: '/tweets/search',
data: { keyword: input },
dataType: 'json'
})
.done(function(tweets) {
search_list.empty();
if (tweets.length !== 0) {
tweets.forEach(function(tweet){
appendTweet(tweet);
});
}
else {
appendErrMsgToHTML("一致するツイートがありません");
}
})
});
});
|
かなり複雑なコードを追加しているように見えますが、全くそんなことはない。
先ほど削除した投稿情報のhtmlをもう一度作成しているだけである。
注意しなければいけない点は、index.html.erbで<%= %>で出力しているものをjbuilderで取得した値に変えていること。
<%= %>で出力しているものは、jbulider取得した値を${}で出力することができる。
次に、tweetsに投稿の情報が入っていない場合のappendErrMsgToHTML関数を定義する。
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 |
$(function() {
var search_list = $(".contents.row");
function appendTweet(tweet) {
if(tweet.user_sign_in && tweet.user_sign_in.id == tweet.user_id){
var current_user = `<li>
<a href="/tweets/${tweet.id}/edit" data-method="get" >編集</a>
</li>
<li>
<a href="/tweets/${tweet.id}" data-method="delete" >削除</a>
</li>`
} else {
var current_user = ""
}
var html = `<div class="content_post" style="background-image: url(${tweet.image});">
<div class="more">
<span><img src="/assets/arrow_top.png"></span>
<ul class="more_list">
<li>
<a href="/tweets/${tweet.id}" data-method="get" >詳細</a>
</li>
${current_user}
</ul>
</div>
<p>${tweet.text}</p><br>
<span class="name">
<a href="/users/${tweet.user_id}">
<span>投稿者</span>${tweet.nickname}
</a>
</span>
</div>`
search_list.append(html);
}
function appendErrMsgToHTML(msg) {
var html = `<div class='name'>${ msg }</div>`
search_list.append(html);
}
$(".search-input").on("keyup", function() {
var input = $(".search-input").val();
$.ajax({
type: 'GET',
url: '/tweets/search',
data: { keyword: input },
dataType: 'json'
})
.done(function(tweets) {
search_list.empty();
if (tweets.length !== 0) {
tweets.forEach(function(tweet){
appendTweet(tweet);
});
}
else {
appendErrMsgToHTML("一致するツイートがありません");
}
})
});
});
|
これもtweetsに値が入っている場合とやっていることが同じ。
コントローラーで検索をかけ、その投稿情報がなかった場合は、「一致するツイートがありません」という文字列を引数に渡してHTML要素を作成しビューに追加している。
実際にインクリメンタルサーチができるか確認
下図のように、インクリメンタルサーチができれば成功。
6. エラー時の処理を行う
アラートで「投稿検索に失敗しました」と表示ができれば十分。
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 |
$(function() {
var search_list = $(".contents.row");
function appendTweet(tweet) {
if(tweet.user_sign_in && tweet.user_sign_in.id == tweet.user_id){
var current_user = `<li>
<a href="/tweets/${tweet.id}/edit" data-method="get" >編集</a>
</li>
<li>
<a href="/tweets/${tweet.id}" data-method="delete" >削除</a>
</li>`
} else {
var current_user = ""
}
var html = `<div class="content_post" style="background-image: url(${tweet.image});">
<div class="more">
<span><img src="/assets/arrow_top.png"></span>
<ul class="more_list">
<li>
<a href="/tweets/${tweet.id}" data-method="get" >詳細</a>
</li>
${current_user}
</ul>
</div>
<p>${tweet.text}</p><br>
<span class="name">
<a href="/users/${tweet.user_id}">
<span>投稿者</span>${tweet.nickname}
</a>
</span>
</div>`
search_list.append(html);
}
function appendErrMsgToHTML(msg) {
var html = `<div class='name'>${ msg }</div>`
search_list.append(html);
}
$(".search-input").on("keyup", function() {
var input = $(".search-input").val();
$.ajax({
type: 'GET',
url: '/tweets/search',
data: { keyword: input },
dataType: 'json'
})
.done(function(tweets) {
search_list.empty();
if (tweets.length !== 0) {
tweets.forEach(function(tweet){
appendTweet(tweet);
});
}
else {
appendErrMsgToHTML("一致するツイートがありません");
}
})
.fail(function() {
alert('error');
});
});
});
|
サーバーエラーの場合、このfailの関数が呼ばれる。
以上でインクリメンタルサーチの実装は終了!
namespaceを利用したPicTweetにも実装する
7つのアクションのみで検索機能を実装した場合、つまり、namespaceを利用したルーティングやコントローラーの定義があるPictweetでも、インクリメンタルサーチを実装する方法を考える。
2つのPictweetの相違点
主に編集するファイル場所の違いと、それによるリクエストパスの違いになる。
- 検索に使用するコントローラーとビューの「名前」「場所」が異なる
- 構造が異なるため、ルーティングも異なる
- ルーティングが異なるため、検索のリクエストパスが異なる
コントローラーとビューの「名前」「場所」が異なる
7つのアクションのみで検索機能を実装した場合、searches_controller.rbの作成にnamespaceを利用。
そのため、コントローラーが格納されているディレクトリの構造が変わる。app/controllers/tweets/searches_controller.rb
加えて、作成したsearches_controller.rbのindexアクションに対応するビューは、app/views/tweets/searches/
にindex.html.erb
として格納される。
これらの「namespaceを利用したことによる構造と名前の違い」がある。
ルーティングが異なる
namespaceを利用したことで、構造が変わったコントローラーになるため、
ルーティングも変更される。
1 2 3 4 5 6 7 8 9 |
検索のリクエストパスが異なる
ルーティングが変更されるため、それに伴って、検索機能を呼び出すリクエストを送信する処理を記述するJSファイルで、指定するリクエストパスを変更する必要がある。これらの違いを踏まえて、
インクリメンタルサーチを実装する。
0. 事前に必要な記述を確認
jQueryがRailsに導入できているか確認する
1 2 3 4 5 6 7 |
上記記述が漏れていれば、追記してbundle install
も実行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// This is a manifest file that'll be compiled into application.js, which will include all the files
// listed below.
//
// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, or any plugin's
// vendor/assets/javascripts directory can be referenced here using a relative path.
//
// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
// compiled file. JavaScript code in this file should be added after the last require_* statement.
//
// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
// about supported directives.
//
//= require jquery
//= require rails-ujs
//= require activestorage
//= require turbolinks
//= require_tree .
|
textカラムにインデックスを設定
1 |
$ rails g migration AddIndexToTweets
|
1 2 3 4 5 |
class AddIndexToTweets < ActiveRecord::Migration
def change
add_index :tweets, :text, length: 32
end
end
|
1 |
$ rails db:migrate
|
1. ルーティングなどAPI側の準備
アクションの中でHTMLとJSONなどのフォーマット毎に条件分岐するため、respond_to
を使用する。
respond_toを利用してフォーマット毎に処理を分ける
記述する内容は全く同じだが、
今回編集するファイルはsearches_controller.rbのindexアクション。
1 2 3 4 5 6 7 8 9 |
「index.json.jbuilder」というファイルを以下のディレクトリに作成する
作成する場所となるディレクトリが異なる点に注意。
また、ファイル名は、index.json.jbuilder
になる。
記述内容は同様。
1 2 3 4 5 6 7 8 |
2. テキストフィールドを作成
こちらは同様にすでに検索窓は作成されているとして、説明は割愛。
3. テキストフィールドが入力されるたびにイベントが発火するようにする
JSファイルを作成しますが、こちらは場所もファイル名も全て同じものになる。
search.jsというファイルを以下のディレクトリに作成
- app
- assets
- javascripts
- search.js
- javascripts
- assets
記述内容は、ここの段階ではまだ変わらない。
1 2 3 4 5 |
$(function() {
$(".search-input").on("keyup", function() {
var input = $(".search-input").val();
});
});
|
4. イベント時に非同期通信できるようにする
ここでAjaxを用いてリクエストを送信するが、ルーティングが異なるため、送信するリクエストパスのみ変更がある。
/tweets/search
となっていたリクエストパスは、/tweets/searches
へ変更している。
1 2 3 4 5 6 7 8 9 10 11 |
5. 非同期通信の結果を得て、HTMLを作成
非同期通信の結果を元にビューを生成する。
同様の処理を記述していく。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
$(function() {
$(".search-input").on("keyup", function() {
var input = $(".search-input").val();
$.ajax({
type: 'GET',
url: '/tweets/searches',
data: { keyword: input },
dataType: 'json'
})
.done(function(tweets) {
$(".contents.row").empty();
if (tweets.length !== 0) {
tweets.forEach(function(tweet){
appendTweet(tweet);
});
} else {
appendErrMsgToHTML("一致するツイートがありません");
}
})
});
});
|
同様に、tweetsに投稿の情報が入っている場合のappendTweet関数と、
tweetsに投稿の情報が入っていない場合のappendErrMsgToHTML関数を定義する。
記述内容は同じ。
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 |
$(function() {
var search_list = $(".contents.row");
function appendTweet(tweet) {
if(tweet.user_sign_in && tweet.user_sign_in.id == tweet.user_id){
var current_user = `<li>
<a href="/tweets/${tweet.id}/edit" data-method="get" >編集</a>
</li>
<li>
<a href="/tweets/${tweet.id}" data-method="delete" >削除</a>
</li>`
} else {
var current_user = ""
}
var html = `<div class="content_post" style="background-image: url(${tweet.image});">
<div class="more">
<span><img src="/assets/arrow_top.png"></span>
<ul class="more_list">
<li>
<a href="/tweets/${tweet.id}" data-method="get" >詳細</a>
</li>
${current_user}
</ul>
</div>
<p>${tweet.text}</p><br>
<span class="name">
<a href="/users/${tweet.user_id}">
<span>投稿者</span>${tweet.nickname}
</a>
</span>
</div>`
search_list.append(html);
}
function appendErrMsgToHTML(msg) {
var html = `<div class='name'>${ msg }</div>`
search_list.append(html);
}
$(".search-input").on("keyup", function() {
var input = $(".search-input").val();
$.ajax({
type: 'GET',
url: '/tweets/searches',
data: { keyword: input },
dataType: 'json'
})
.done(function(tweets) {
search_list.empty();
if (tweets.length !== 0) {
tweets.forEach(function(tweet){
appendTweet(tweet);
});
} else {
appendErrMsgToHTML("一致するツイートがありません");
}
})
});
});
|
6. エラー時の処理を行う
通信に失敗した場合の処理を実装して完了。
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 |
$(function() {
var search_list = $(".contents.row");
function appendTweet(tweet) {
if(tweet.user_sign_in && tweet.user_sign_in.id == tweet.user_id){
var current_user = `<li>
<a href="/tweets/${tweet.id}/edit" data-method="get" >編集</a>
</li>
<li>
<a href="/tweets/${tweet.id}" data-method="delete" >削除</a>
</li>`
} else {
var current_user = ""
}
var html = `<div class="content_post" style="background-image: url(${tweet.image});">
<div class="more">
<span><img src="/assets/arrow_top.png"></span>
<ul class="more_list">
<li>
<a href="/tweets/${tweet.id}" data-method="get" >詳細</a>
</li>
${current_user}
</ul>
</div>
<p>${tweet.text}</p><br>
<span class="name">
<a href="/users/${tweet.user_id}">
<span>投稿者</span>${tweet.nickname}
</a>
</span>
</div>`
search_list.append(html);
}
function appendErrMsgToHTML(msg) {
var html = `<div class='name'>${ msg }</div>`
search_list.append(html);
}
$(".search-input").on("keyup", function() {
var input = $(".search-input").val();
$.ajax({
type: 'GET',
url: '/tweets/search',
data: { keyword: input },
dataType: 'json'
})
.done(function(tweets) {
search_list.empty();
if (tweets.length !== 0) {
tweets.forEach(function(tweet){
appendTweet(tweet);
});
} else {
appendErrMsgToHTML("一致するツイートがありません");
}
})
.fail(function() {
alert('error');
});
});
});
|