3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【インクリメンタルサーチ】Ajaxを用いたキーワード検索機能の実装

Posted at

#インクリメンタルサーチとは

キーワード検索を行う際に、利用者が文字を入力するたびに検索を実行する方式。
検索語全体を入力する前に検索を開始し、一文字進むごとに検索結果が更新し即座に候補を表示させる便利な機能。

#実装内容
今回はDBに保存してあるカラム値を対象にインクリメンタルサーチを使用して選択した値をフォームに自動入力させます。

##処理全体の流れ

  1. 検索フォームに入力後、jsファイルでイベントが発火
  2. jsファイルから受け取ったデータをコントローラのアクションへ返す
  3. データベースからjsファイルで受け取ったデータと合致するデータを抽出し、そのデータをjbuilderへ渡す
  4. jbuilderでJSON形式に変換したデータを再度jsファイルに送り、そのデータをもとに検索結果を表示

この説明では何いっているか分かりませんよね、、(自分でも分かりません笑)

#完成形はこちら
4635318dc0d230da36ba06d6b026197b (2).gif

#環境
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としています。

config/route.rb
 # インクリメンタルサーチ専用のルーティング
 get 'travel_records/search', to: 'travel_records#search'

#searchアクションの設定

app/controllers/travel_records.controller.rb
class TravelRecordsController < ApplicationController
  def search
  end
end

#検索フォームと検索結果表示用のフォームの作成

まず最初に、今回の投稿機能ではスポット詳細ページ経由からの投稿スポットと紐付いていない場合の投稿で分けており、後者でインクリメンタルサーチを使用しております。※前者の説明は省きます。

スポット詳細ページ経由からの投稿・・・スポット詳細ページからクエリ形式でspot_idを取得可能な状態
スポットと紐付いていない場合の投稿・・・ヘッダーに搭載している投稿機能のため、スポットに関する情報がない状態

app/views/travel_records/new.html.slim
/----- スポット詳細ページ経由でのスポット名自動入力 -----
- 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ファイルの作成
ここでは検索フォームに入力されたデータを受け取り、受け取ったデータをコントローラへ送っています。

app/javascript/spot_search.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変数に代入しています(下記参照)

app/javascript/spot_search.js
  $("#spot_search").on("keyup", function () {
    let input = $("#spot_search").val();

次に非同期通信を行うため、指定する内容を記述しています。
url、data内のkeyword名は、ルーティングやインスタンス変数の定義により変わるので適宜修正が必要です
今回の記述では、input変数に格納したデータをkeywordに代入し、そのデータをGETメソッドtravel_recordsコントローラのsearchアクションに送り、そのデータをJSON形式で返しています。

app/javascript/spot_search.js
$.ajax({
  type: 'GET',
  url: '/travel_records/search',
  data: {
    keyword: input
  },
  dataType: 'json'
})

#searchアクションの編集
jsファイルから送られてきたデータと合致するデータをspotsテーブルnameカラム(スポット名)locationカラム(スポットの所在地)の2つを対象に検索を行なっています。

app/controllers/travel_records.controller.rb
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とすることで該当コントローラの該当アクションに対応するよう作成します。

app/views/travel_records/search.json.jbuilder
json.array! @spots, :id, :name, :location # idはspotsテーブルとの紐付けで必要になります

先ほどコントローラ内で定義したインスタンス変数(@spots)に複数のデータが存在する場合があるため、array!メソッドを使用し、JSON形式に変換したデータ配列に格納して返しています。

参考記事:https://qiita.com/ryouzi/items/06cb0d4aa7b6527b3645

#jsファイルの編集
jbuilderファイルでJSON形式に変換したデータが戻ってきてからの処理をdoneメソッド以下に追記しています。

app/javascript/spot_search.js
  $("#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メソッド要素を非表示にしています。

その後、条件分岐を使用して各処理を記述しています。
該当スポットが存在する場合・・・セレクトフォームから検索結果を選べるようにしています。
検索フォームに何も入力がない場合・・・ラベルと選択フォームを非表示
入力された値に該当するスポットが存在しない場合・・・「該当するスポットはありません」と表示されます。

入力された値に該当するスポットが存在しない場合の表示画面(下記参照)
35fed71106880b309e3a0793a20665d4.png

#jsファイルの編集
条件分岐の処理部分で記述していた関数を定義し、検索結果を画面に表示させるための記述をしています。

app/javascript/spot_search.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)を追加しています(下記参照)

app/javascript/spot_search.js
// 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ファイルの編集
ここでは、曖昧検索で合致したスポットをセレクトフォームから選んでクリックした時の処理を記述しています。

app/javascript/spot_search.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");
  });

jQueryonメソッドを使ってlist-itemのクリックイベントを受け取っています。

まず、先ほどのdata属性に格納したデータを取り出し、spot_dataに格納しています。
datasetオブジェクトを通してdata属性の値を取得しており、属性名のdata-より後の部分を使用することで取得できます。
今回の場合、data-spot-nameとしているのでdatasetの後にspotName(キャメルケースに変更)と記述します。

app/javascript/spot_search.js
$("#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テーブルとの紐付けを行なっています。

app/javascript/spot_search.js
$("#spot_search").val(spot_data);

spot_dataに格納されたデータを入力フォームに挿入し、スポット名を表示させています。

以上で、インクリメンタルサーチを使用したキーワード検索機能の完成です!

#jsファイル全文

app/javascript/spot_search.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についての理解が浅く、何度も何度も調べながらすんごい時間をかけて記事を作成しました笑
しかし前回もそうですが、実装したことを記事にすることで良いアウトプットができており、今後も実装した機能や気になったことを投稿していきます!

間違っている箇所や分かりづらい箇所が多々あるかと思います。
その際は、気軽にコメントいただけれると幸いです。

最後までご覧いただき、ありがとうございました!

#参考記事

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?