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

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

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

##処理全体の流れ

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

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

#完成形はこちら
![4635318dc0d230da36ba06d6b026197b (2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1131643/42eccf73-5000-49a6-4ed4-249f45d7f836.gif)

#環境
`macOS Big Sur 11.2.3`
`ruby: 2.7.2`
`rails: 6.1.3`
`jQuery`
`テンプレートエンジン: slim`
`レイアウト: bootstrap4`

#前提

- 関連モデルやコントローラ、テーブルの作成は省いております

- jQuery等のgemの導入は省いております

#アソシエーション

`User : TravelRecord = １対多`
`User : Spot = １対多`
`Spot : TravelRecord = １対多`

今回は、**travel_recordsコントローラ**の`newアクション`から**soptsテーブル**の`nameカラム(スポット名)`と`locationカラム(所在地名)`の２つのキーワードを対象にインクリメンタルサーチを行なっています。



#ルーティングの設定
検索機能のため、アクション名は分かりやすく`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カラム(スポットの所在地)`の２つを対象に検索を行なっています。

```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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1131643/2925b3ed-a6f4-1cf1-a490-a3d91493b069.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");
  });
```

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

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

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

#参考記事

https://qiita.com/junyais30st/items/f1ad5bd3b025434983b7

https://qiita.com/iiyama_makoto/items/49cbd18c6f57313e6c21