Rails7のHotwireを使ってインクリメンタルサーチの実装についてまとめみた

## 前提

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

> インクリメンタルサーチ**（[英語](https://ja.wikipedia.org/wiki/%E8%8B%B1%E8%AA%9E): incremental search）は、[アプリケーション](https://ja.wikipedia.org/wiki/%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2)における検索方法のひとつ。検索したい単語をすべて入力した上で検索するのではなく、入力のたびごとに即座に候補を表示させる。逐語検索、逐次検索とも。(参照：Wikipwdia)

入力の度に検索候補が出てくる検索機能のことである。

#### オートコンプリートとの違い

似たようなことでオートコンプリートというのもある。
このオートコンプリートは、次に入力される内容を予想して表示する仕組みのことである。

まとめると
| 項目 | インクリメンタルサーチ | オートコンプリート |
| --- | --- | --- |
| 表示内容 | 検索結果全体 | 入力候補 |
| 更新頻度 | 入力するたびに更新 | 入力するたびに更新 |
| 用途 | 大量のデータから絞り込み | 入力の手間削減、スペルミス防止 |
| 具体例 | 自作のファイル検索 | Google検索のサジェスト、住所入力フォーム |

例えばAmazonのサイトでは、
1. 「あ」と入力すると、**インクリメンタル検索**で「あ」を含む商品が一覧表示される。
2. さらに「い」と入力すると、「あい」を含む商品に絞り込まれると同時に、**オートコンプリート**で「アイフォンケース」「アイシャドウ」などの候補が表示される。

いった流れだ。

簡単に言うと、
- 自分のデータベースから情報を絞り込むことで取得する**インクリメンタル**
- 膨大なデータベースから情報を絞りこみたいときは**オートコンプリート**

### 今回のゴール

投稿（Post）の検索フォームから、本文（body）の内容についての部分検索を実施している。
→
2文字以上入力することで、その検索候補が出てきてその検索候補より直接投稿詳細ページに行けるようしたい。

### そのイメージ

1. ユーザーが検索フォームに文字の入力を始める
1. その際に検索候補が浮かびあがる
1. 更に文字を入力をすることで該当する候補が絞られる
1. その検索候補を当てると、投稿詳細ページに画面遷移する


## 技術選定～タスク整理まで

#### 技術構成

| カテゴリ | 技術内容 |
|---|---|
| サーバーサイド | Ruby 3.2.3 |
| フレームワーク | Rails 7.2.1 |
| フロントエンド | HTML, CSS, Bootstrap, Hotwire（JS） |
| 開発環境 | Docker |


#### Hotwireについて

Hoteireとは、Rails7で出た、Railsを使ったモダンなアプリケーションの作成するための便利な機能です。
ここでは以下の内容を使って記載するので、この実施の点だけ抑えてください。
- **Turbo Streams** ～サーバーからリアルタイムで更新を受信し、ページに反映することができる。非同期処理を実施し部分的に更新をする。つまり、検索候補を出す上で必要な技術
- **Stimulus** ～JavaScriptを最小限に抑えながら、HTMLに動的な振る舞いを追加することが出来るやつ。ユーザーの入力の度にサーバーへ送信するなどのインクリメンタルサーチの肝となる技術

#### 処理の流れ
1. ユーザーが入力を開始
2. Stimulusが入力を制御・最適化
3. Turboが非同期通信を処理
4. Railsが検索を実行
5. 結果がTurboで画面に反映
6. Stimulusで結果の操作を制御
6. 候補を選択すると直接詳細(show)へ遷移

### 実装前のタスク分解
- **コントローラーとルート**
- [ ] 検索候補を出すルーティングの設定
- [ ] コントローラーsearch_candidatesアクションを定義
- [ ] @search_candidatesというインスタンス変数に検索候補を格納させる

- **Stimulusコントローラー**
- [ ] jsbundlingでStimulusコントローラーの作成
- [ ] connect()メソッドで初期化処理を行い、suggest()メソッドで検索リクエストを送信する処理
- [ ] fetchメソッドで非同期処理の定義と検索候補をHTMLで返すように記載
- [ ] Stimulusコントローラーをビルドして、JavaScriptファイルを生成

- **ビューファイル**
- [ ] 検索フォームにdata-controller="search"属性を追加し、Stimulusコントローラーを適用させる
- [ ] data-action="input->search#suggest"属性を追加し、入力時にsuggest()メソッドが呼び出されるように設定
- [ ] 検索候補を表示するための要素を追加(div id="search-candidates"></div>)
- [ ] _search_candidates.html.erb の作成
- [ ] 直接遷移ができるように実装
- [ ] 検索候補の文字数の制限

という内容でタスクを整理。

## 実装
 
### ルーティングの追加


```
    collection do
      get 'search'
      get 'search_candidates'
    end
```

### コントローラーに検索候補アクションを追加（app/controllers/posts_controller.rb)

- 検索候補のインスタンス変数を設定
- bodyカラムに対して検索とその検索としての条件を設定
- Turbo Streamsを使って検索候補を出すという設定
```
  # 検索候補
  def search_candidates
    return if params[:keyword].blank? || params[:keyword].length < 2

    @candidates = Post.where("body LIKE ?", "%#{params[:keyword]}%")
                    .select(:id,:body) # 投稿IDもセットで
                    .distinct
                    .limit(5)

    respond_to do |format|
      format.turbo_stream {
        render turbo_stream: turbo_stream.update(
          "search-candidates",
          partial: "posts/search_candidates",
          locals: { candidates: @candidates }
        )
      }
    end
  end
```



### Stimulusコントローラー（app/javascript/controllers/search_controller.js）
このStimulusコントローラーの作成はrails generate stimulus Search コマンドを使って作成します。
最後には、./bin/dev コマンドでビルドを忘れずに実施しましょう。
- 検索候補は2文字以上から処理
- 300ミリ秒のデバウンス処理
- Turbo Streams形式のレスポンスを期待するように記載
- レスポンスをテキスト形式で取得
- 取得したHTMLを search-candidates というIDを持つ要素に設定し、検索候補を表示

```
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["input"]

  connect() {
    this.timeout = null
    console.log("Search controller connected") // 動作確認用
  }

  suggest() {
    // インクリメンタルサーチの実装
    clearTimeout(this.timeout)
    const query = this.inputTarget.value

    // 検索候補をクリア
    if (query.length < 2) {
      document.getElementById("search-candidates").innerHTML = ""
      return
    }

    // デバウンス処理（300ms）
    this.timeout = setTimeout(() => {
      const url = `/posts/search_candidates?keyword=${encodeURIComponent(query)}`

      // 非同期処理にリクエスト
      fetch(url, {
        headers: {
          "Accept": "text/vnd.turbo-stream.html",
          "X-Requested-With": "XMLHttpRequest"
        }
      })
      // 検索候補をHTMLに返すことを
      .then(response => response.text())
      .then(html => { //検索候補がレンダリングして実際に表示される
        document.getElementById("search-candidates").innerHTML = html;
      });
    }, 300);
  }
}
```

### 検索フォームを編集（app/views/posts/_search.html.erb）
- search コントローラーをこの要素に適用
- search コントローラーの suggest メソッドを呼び出す
- search コントローラーの input ターゲットとして指定
- 検索候補として search-candidates というIDを付与

```
      <div class="col-md-5 mb-1 position-relative">
        <div class="form-group">
          <%= f.label :body_cont, "フリーワード検索", class: "form-label" %>
          <%= f.search_field :body_cont, 
              class: 'form-control', 
              placeholder: '検索ワード', 
              data: { 
                controller: "search", 
                action: "input->search#suggest",
                search_target: "input"
              }
          %>
          <%# 検索候補表示エリア %>
          <div id="search-candidates" class="search-candidates"></div>
        </div>
      </div>
```

### 検索候補を表示するファイルの作成（app/views/posts/_search_candidates.html.erb）
- 検索候補があるかどうかのif文
- 直接遷移できるようにすること
- 20文字での表示


```
<!-- 検索候補 -->
<% if candidates.any? %>
  <ul class="search-candidates-list">
    <% candidates.each do |candidate| %>
      <li class="candidate-item">
        <%# link_toを使用して直接詳細ページへのリンクを作成 %>
        <%= link_to post_path(candidate), class: "candidate-item" do %>
          <!-- 検索候補の文字数を20文字の表記 -->
          <%= truncate(highlight(candidate.body, params[:keyword]), length: 20) %>
        <% end %>
      </li>
    <% end %>
  </ul>
<% else %>
  <p>検索候補がありません</p>
<% end %>
```

ここまで出来た検索内容は以下の通り
![スクリーンショット 2024-12-03 173301.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3683921/bb705890-591c-8876-db9e-df97ca56d8d2.png)



## 実装での大変だったことやエラーについて
ここでは、自分の実装で起こった失敗談についてまとめていきます。
1. **検索候補を出す処理を非同期処理が正解に対して、検索事態を非同期処理にしてしまったこと**
    turbo-frameを使って検索結果事態を非同期処理にしてしまってました。
    今回の実装はインクリメンタルサーチで検索候補を表示させることでした。
    なので、 turbo_streamとStimulusコントローラーを使って部分的に実装しようという風に考えました
1. **jsbundling-railsをやめてimportmap-railsの導入検討**
    Stimulusコントローラーを読み込みさせるという問題が解決できずに、importmap-railsの導入を検討しました。
    jsbundling-railsを使っている理由はそんなになかったので、変えようと思ったが、依存関係とかがごちゃごちゃになってしまうために断念。
    自分のような初学者の場合は、JSのコードに対する理解が少ない分、importmap-railsで実施すべきだったと後悔
1. **Stimulusコントローラーが読み込みされない**
    この問題としては、自分がRailsと環境でのJavaScriptを読み込ませるのかということが理解できていなくて、
    言い換えると、package.json と yarn.lock の2つのファイル関係の理解がなかった。
    つまり、yarn add @hotwired/stimulusの実行がないままで実装をしていたので、読み込みされなかたったという問題がありました
    
1. **開発環境で読み込みされずにJSファイルが読み込みされない**
    ビルドされずに、開発環境だけ今回作成したStimulusコントローラーが読み込みされないという問題がありました。
    だったので、本番環境時にデプロイと同時に実施される、rails assets:precompile を実施することでテスト環境でも呼び込むようになりました。
    理由は不明ですが、
1. **検索候補が出てこない**
    suggest() メソッド内で検索候補をHTMLで返すコードの記載

## 最後に
ここまで見ていて頂いてありがとうございます。
インクリメンタルサーチはHoteireの知識や、Railsの勉強ばかりしてきた自分にとってはフロントエンドとのつなぎ目を勉強出来る実装だったと思っています。
さらに、ログばかり見るものでした、開発者ツールをみて修正をするのも非常にいい経験でした。
実装する方の参考になればと思っています。
私自身は実務経験のものなので、間違いがあるかと思います。なにかございましたらコメントください。

ありがとうございました。