#インクリメンタルサーチとは
キーワード検索を行う際に、利用者が文字を入力するたびに検索を実行する方式。
検索語全体を入力する前に検索を開始し、一文字進むごとに検索結果が更新し即座に候補を表示させる便利な機能。
#実装内容
今回はDBに保存してあるカラム値を対象にインクリメンタルサーチを使用して選択した値をフォームに自動入力させます。
##処理全体の流れ
- 検索フォームに入力後、jsファイルでイベントが発火
- jsファイルから受け取ったデータをコントローラのアクションへ返す
- データベースからjsファイルで受け取ったデータと合致するデータを抽出し、そのデータをjbuilderへ渡す
- jbuilderでJSON形式に変換したデータを再度jsファイルに送り、そのデータをもとに検索結果を表示
この説明では何いっているか分かりませんよね、、(自分でも分かりません笑)
#環境
macOS Big Sur 11.2.3
ruby: 2.7.2
rails: 6.1.3
jQuery
テンプレートエンジン: slim
レイアウト: bootstrap4
#前提
-
関連モデルやコントローラ、テーブルの作成は省いております
-
jQuery等のgemの導入は省いております
#アソシエーション
User : TravelRecord = 1対多
User : Spot = 1対多
Spot : TravelRecord = 1対多
今回は、travel_recordsコントローラのnewアクション
からsoptsテーブルのnameカラム(スポット名)
とlocationカラム(所在地名)
の2つのキーワードを対象にインクリメンタルサーチを行なっています。
#ルーティングの設定
検索機能のため、アクション名は分かりやすくsearch
としています。
# インクリメンタルサーチ専用のルーティング
get 'travel_records/search', to: 'travel_records#search'
#searchアクションの設定
class TravelRecordsController < ApplicationController
def search
end
end
#検索フォームと検索結果表示用のフォームの作成
まず最初に、今回の投稿機能ではスポット詳細ページ経由からの投稿
とスポットと紐付いていない場合の投稿
で分けており、後者でインクリメンタルサーチ
を使用しております。※前者の説明は省きます。
スポット詳細ページ経由からの投稿・・・スポット詳細ページからクエリ形式でspot_idを取得可能な状態
スポットと紐付いていない場合の投稿・・・ヘッダーに搭載している投稿機能のため、スポットに関する情報がない状態
/----- スポット詳細ページ経由でのスポット名自動入力 -----
- if @spot.present?
= f.hidden_field :spot_id, value: @spot.id
.form-group.mt-4
= f.input :place, input_html: { value: @spot.name, readonly: true }, required: false
/----- スポットと紐付いていない場合 -----
- else
<button type="button" id="spot_data" class="btn bg-white btn-block border mb-8" data-toggle="modal" data-target="#Modal">
<span class="spot_name float-left">スポット名・住所を入力してください</span>
</button>
= f.hidden_field :spot_id, id: "spot_id"
/----- モーダルフォーム(インクリメンタルサーチ) -----
<div class="modal fade" id="Modal" tabindex="-1" role="dialog" aria-labelledby="basicModal" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-body">
/ 入力フォーム
.form-group
= f.input :place, :placeholder => "スポット名・住所を入力してください", label: false, required: false, :input_html => { id: "spot_search", autocomplete: "off" }
/ 検索結果表示用のフォーム
.form-group
<label for="select_form" id="label">該当スポット一覧</label>
<select multiple class="form-control" id="select_form"></select>
/ 該当スポートが存在しない場合の表示(後ほど説明します)
ul.no-spot
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">閉じる</button>
</div>
</div>
</div>
</div>
/----- ここまで -----
検索結果表示用のフォームにはbootstrap4のモーダル機能
を使用しています。
jQueryを使用するため、入力フォームと検索結果表示用のフォーム
にはidを付与しています。
※ モーダルを使用しているため、コードが長く読みにくくなっています、、
#jsファイルの作成
ここでは検索フォームに入力されたデータを受け取り、受け取ったデータをコントローラへ送っています。
$("#spot_search").on("keyup", function () {
let input = $("#spot_search").val();
$.ajax({
type: 'GET',
url: '/travel_records/search',
data: {
keyword: input
},
dataType: 'json'
})
入力フォームに付けたid名(#spot_search)
を指定し、文字入力でキーを離したタイミングでkeyupイベントが発火
。
検索フォームで入力されたデータをinput変数
に代入しています(下記参照)
$("#spot_search").on("keyup", function () {
let input = $("#spot_search").val();
次に非同期通信を行うため、指定する内容を記述しています。
url、data内のkeyword名
は、ルーティングやインスタンス変数の定義により変わるので適宜修正が必要です。
今回の記述では、input変数に格納したデータをkeywordに代入
し、そのデータをGETメソッド
でtravel_recordsコントローラのsearchアクション
に送り、そのデータをJSON形式
で返しています。
$.ajax({
type: 'GET',
url: '/travel_records/search',
data: {
keyword: input
},
dataType: 'json'
})
#searchアクションの編集
jsファイルから送られてきたデータと合致するデータをspotsテーブルのnameカラム(スポット名)
とlocationカラム(スポットの所在地)
の2つを対象に検索を行なっています。
class TravelRecordsController < ApplicationController
def search
return nil if params[:keyword] == ""
@spots = Spot.where('name LIKE ? OR location LIKE ?', "%#{params[:keyword]}%", "%#{params[:keyword]}%")
respond_to do |format|
format.html
format.json
end
end
params[:keyword]
は、先ほど作成したjsファイルのajax以下で記述したdata:{keyword: input}
から来ています。
※ jsファイル内では検索フォームで入力したデータ
をinput(変数)として扱い、コントローラ内
ではkeywordとして扱っているため、とても紛らわしいです、、
検索フォームに入力がない場合
はnilを返し、入力データがある場合
、DBから検索対象のカラム値と部分一致しているデータを引っ張ってきて**@spotsに格納しています。
※ params[:keyword]を%で囲うこと
で特定の文字を含む語句を曖昧検索**しています。
最後にrespond_toメソッド
を使用し、@spotsに格納したデータをこの後に作成するjbuilderファイル
に送っています。
#jbuilderファイルの作成
jbuilderとは、RailsのGemfileにデフォルトで含まれている「JSON形式のデータを簡単に作成する事が出来るgemのこと」
です。
ファイル名には命名規則があり、app/views/(該当コントローラ名)/(該当アクション名).json.jbuilder
とすることで該当コントローラの該当アクションに対応するよう作成します。
json.array! @spots, :id, :name, :location # idはspotsテーブルとの紐付けで必要になります
先ほどコントローラ内で定義したインスタンス変数(@spots)
に複数のデータが存在する場合があるため、array!メソッド
を使用し、JSON形式に変換したデータ
を配列に格納して返しています。
参考記事:https://qiita.com/ryouzi/items/06cb0d4aa7b6527b3645
#jsファイルの編集
jbuilderファイルでJSON形式に変換したデータが戻ってきてからの処理をdoneメソッド
以下に追記しています。
$("#spot_search").on("keyup", function () {
let input = $("#spot_search").val();
$.ajax({
type: 'GET',
url: '/travel_records/search',
data: {
keyword: input
},
dataType: 'json'
})
// ----- ここから追記 -----
.done(function (spots) {
$(".no-spot").empty(); // 該当するスポットが存在しない場合に表示させるメッセージを削除
$("#select_form").empty(); // に該当スポットを選べる選択フォームを削除
$("#label,#select_form").hide(); // 該当スポットが存在する場合に表示されるラベルと選択フォームを非表示
// 取得したデータと合致した場合の処理
if (spots.length !== 0) {
spots.forEach(function (spot) {
addSpot(spot);
});
// データが空(何も入力されていない)の場合の処理
} else if (input.length == 0) {
("#label,#select_form").hide();
return false;
// 取得したデータに該当するスポットが存在しない場合の処理
} else {
addNoSpot("該当するスポットはありません");
}
})
// ------ ここまで ------
});
**doneメソッドの引数(spots)**の中には、先ほどJSON形式に変換したデータ
が入っています。
emptyメソッドで要素内の子要素を削除
、hideメソッドで要素を非表示
にしています。
その後、条件分岐を使用して各処理を記述しています。
該当スポットが存在する場合
・・・セレクトフォームから検索結果を選べるようにしています。
検索フォームに何も入力がない場合
・・・ラベルと選択フォームを非表示
入力された値に該当するスポットが存在しない場合
・・・「該当するスポットはありません」と表示されます。
※ 入力された値に該当するスポットが存在しない場合
の表示画面(下記参照)
#jsファイルの編集
条件分岐の処理部分で記述していた関数を定義し、検索結果を画面に表示させるための記述をしています。
// ----- ここから追記 -----
function addSpot(spot) {
$("#label, #select_form").show();
let html = `
<option class="list-item"
value = "${spot.id}"
data-spot-name = "${ spot.name }"
data-spot-location = "${ spot.location }"
>
${spot.name || spot.location}</option>
`;
$("#select_form").append(html);
};
function addNoSpot(message) {
let html = `${message}`
$(".no-spot").append(html);
};
// ------ ここまで ------
$("#spot_search").on("keyup", function () {
let input = $("#spot_search").val();
$.ajax({
type: 'GET',
url: '/travel_records/search',
data: {
keyword: input
},
dataType: 'json'
})
addSpot以下では**変数(html)**に検索結果用のフォーム
を記述しています。
まず、option要素
を作成し、その中に${spot.name || spot.location}
と記述することで曖昧検索で合致したスポット名を表示させています。
次に、value属性にspot.id
を格納しています。これは、spotsテーブルとの紐付け
を行なうために必要になります。
次に、data属性を使用して各カラムのデータを格納しています。これは、セレクトフォームから選んだ値を入力フォームに自動入力させるために必要になります。
※ data-
から始まるものをdata属性といい、HTML要素に追加情報を格納することができます。
参考記事: https://developer.mozilla.org/ja/docs/Learn/HTML/Howto/Use_data_attributes
最後に、appendメソッドを使用して対象の要素内("select_form")の最後に先ほど定義した変数(html)
を追加しています(下記参照)
// before
<select multiple class="form-control" id="select_form"></select>
// after
<select multiple class="form-control" id="select_form">
<option class="list-item" value = "spot.id" data-spot-name="スポット名"data-spot-location="所在地名">
スポット名
</option>
</select>
#jsファイルの編集
ここでは、曖昧検索で合致したスポットをセレクトフォームから選んでクリックした時の処理を記述しています。
// ----- 先頭に追記 -----
$(function () {
$("#label,#select_form").hide();
$("#select_form").on("click", ".list-item", function (event) {
const spot_data = event.target.dataset.spotName || event.target.dataset.spotLocation;
const spot_id = event.target.value;
$("#spot_search").val(spot_data);
$("#spot_id").val(spot_id);
$(".spot_name").text(spot_data);
$("#Modal").modal("hide");
});
jQueryのonメソッド
を使ってlist-item
のクリックイベントを受け取っています。
まず、先ほどのdata属性に格納したデータを取り出し、spot_data
に格納しています。
datasetオブジェクト
を通してdata属性の値
を取得しており、属性名のdata-
より後の部分を使用することで取得できます。
今回の場合、data-spot-name
としているのでdatasetの後にspotName(キャメルケースに変更)
と記述します。
$("#spot_id").val(spot_id);
// app/views/travel_records/new.html.slim
= f.hidden_field :spot_id, id: "spot_id"
先ほどvalue属性に格納したspot.idをspot_id
に格納し、そのデータをhidden_field
に渡すことでspotsテーブルとの紐付け
を行なっています。
$("#spot_search").val(spot_data);
spot_data
に格納されたデータを入力フォームに挿入し、スポット名を表示させています。
以上で、インクリメンタルサーチを使用したキーワード検索機能の完成です!
#jsファイル全文
$(function () {
$("#label,#select_form").hide();
$("#select_form").on("click", ".list-item", function (event) {
const spot_data = event.target.dataset.spotName || event.target.dataset.spotLocation;
const spot_id = event.target.value;
$("#spot_search").val(spot_data);
$("#spot_id").val(spot_id);
$(".spot_name").text(spot_data);
$("#Modal").modal("hide");
});
function addSpot(spot) {
$("#label, #select_form").show();
let html = `
<option class="list-item"
value = "${spot.id}"
data-spot-name = "${ spot.name }"
data-spot-location = "${ spot.location }"
>
${spot.name || spot.location}</option>
`;
$("#select_form").append(html);
};
function addNoSpot(message) {
let html = `${message}`
$(".no-spot").append(html);
};
$("#spot_search").on("keyup", function () {
let input = $("#spot_search").val();
$.ajax({
type: 'GET',
url: '/travel_records/search',
data: {
keyword: input
},
dataType: 'json'
})
.done(function (spots) {
$(".no-spot").empty();
$("#select_form").empty();
$("#label,#select_form").hide();
if (spots.length !== 0) {
spots.forEach(function (spot) {
addSpot(spot);
});
} else if (input.length == 0) {
("#label,#select_form").hide();
return false;
} else {
addNoSpot("該当するスポットはありません");
}
})
});
});
#最後に
実装した内容を記事にしてみて思ったことは、、**「全然理解できていない」**でした、、
jQureyメソッド
やjavascript
についての理解が浅く、何度も何度も調べながらすんごい時間をかけて記事を作成しました笑
しかし前回もそうですが、実装したことを記事にすることで良いアウトプット
ができており、今後も実装した機能や気になったことを投稿していきます!
間違っている箇所や分かりづらい箇所が多々あるかと思います。
その際は、気軽にコメントいただけれると幸いです。
最後までご覧いただき、ありがとうございました!
#参考記事