【Rails】読み仮名でもオートコンプリート検索できるようにしよう

# 概要
* 検索にオートコンプリートをつけます
* 読み仮名（ひらがな）でもオートコンプリートできるようにします。

# 動作イメージ
![c0edcd5d09620e68158dfd6e8ebcd282.gif](https://qiita-image-store.s3.amazonaws.com/0/124445/c63728bf-1758-e96b-4d4c-063c77268b6f.gif)

# 環境
* rails 6.0.0beta3
* dbはsqliteなのでローカルで適当に試してください。
* gem
    * [fast_jsonapi](https://github.com/Netflix/fast_jsonapi) オートコンプリート用の結果をjsonで返すときにつかう
    * [rubyfuri](https://github.com/karur4n/rubyfuri) 読み仮名を取得
    * [dotenv-rails](https://github.com/bkeepers/dotenv) yahoo japanのapi keyを管理するため
* javascript libary
    * [selectize.js](https://github.com/selectize/selectize.js) フロント側のオートコンプリート表示を制御

# heroku
https://fuchu-nosanbutsu.herokuapp.com/

# コード
https://github.com/junara/fuchu-nosanbutsu/releases/tag/1.1.1

## ローカルで試す場合
gitから https://github.com/junara/fuchu-nosanbutsu/releases/tag/1.1.1 をクローンして下さい。

* bundle install

```
bundle install
```

* migarate

```
bin/rails db:migrate
```

* データを取り込む

```
bin/rails c
> Store.import_csv('db/30chokubaijo_map.csv')
```

* Storeデータからキーワードを登録

```
> importer = ItemImporter.new(Store.pluck(:description))
> importer.execute
```

* ふりがなを登録
  * `.env` にyahoo japanのdeveloper apiを取得して書き込んでください。
  * その後以下を事項
  
```
bin/rake item:name_hiragana
```

* サーバーを起動

```
bin/rail s
```

* webpacker-dev-serverを起動

```
bin/webpack-dev-server 
```

下記にアクセス
http://localhost:3000




# 手順
* サーバーサイド（オートコンプリートエンドポイント作成）
    * データ取り込み
    * ふりがなデータ取得
    * レスポンスのjson出力
    * エンドポイント
        * 複数キーワードで検索できるように修正
* フロントエンド
    * javascript環境のセットアップ
        * jquery, selectizeのインストール
    * オートコンプリートの実装

## サーバーサイド（オートコンプリートエンドポイント作成）
### データ取り込み
オートコンプリート対象となるテーブル `Item` を作成します。

```db/migrate/20190322115059_create_items.rb
class CreateItems < ActiveRecord::Migration[6.0]
  def change
    create_table :items do |t|
      t.string :name
      t.timestamps
    end
  end
end
```

```console
bin/rails db:migrate
```

インポート用の ItemImporterを実装します。これは、Store.descriptionをparseしてItem.nameに取り込むクラスです。

```app/importers/item_importer.rb
class ItemImporter
  def initialize(descriptions)
    @descriptions = descriptions
  end

  def execute
    ActiveRecord::Base.transaction do
      Item.delete_all
      list = []
      item_names.uniq.reject(&:blank?).each do |item_name|
        list << Item.new(name: item_name)
      end
      Item.import list
    end
  end

  private

  def item_names
    current_item_names = Item.pluck(:name)
    @descriptions.each_with_object(current_item_names) do |str, res|
      res.concat(custom_split(str))
    end
  end

  def custom_split(str)
    return [] if str.nil?
    str.gsub(/ほか|など|あり/, '').split(/・|\(|（|）|、|：|\n|　|\)| /)
  end
end
```

これで取り込みを行います。

```console
rails c
> importer = ItemImporter.new(Store.pluck(:description))
> importer.execute
```

これで、item.nameにstore.descriptionのデータからパースされたキーワードが保存されています。

`Item.pluck(:name)` などで確認して下さい。

### ふりがなデータ取得
これだけだと `ナス` や `茄子` を `なす` でオートコンプリートすることができません。そのために、カタカナをひらがなに、さらに茄子を なすに対応させます。

まずふりがな（ひらがな）を格納するカラムをマイグレーションします。


```db/migrate/20190324070509_add_name_furi_to_item.rb
class AddNameFuriToItem < ActiveRecord::Migration[6.0]
  def change
    add_column :items, :name_hiragana, :string
  end
end
```

```console
bin/rails db:migrate 
```

フリガナを追加するためのメソッドを追加します。
Gemfileに下記のgemを追加します。rubyfuriはyahoo japanのapiを叩いて、読み仮名を取得するgemですdotenv-railsはapiキーを管理するためです。

```Gemfile
gem 'dotenv-rails'
gem 'rubyfuri'
```

で、yahoo japanのdeveloper apiキーを取得します。 https://developer.yahoo.co.jp/webapi/jlp/ このあたりから取得してください。（取得方法は、各自調べて下さい。）

取得したapiキーを `.env` ファイルに記載します。（gitignoreに記載しているので大丈夫だと思いますが、こちらのAPIキーは決してgithubにアップロードしないで下さい！）

```.env
YAHOO_JAPAN_DEVELOPER_CLIENT_ID=あなたのAPIキー
```

良みこむためのメソッドをItemに付け加えます。

```app/models/item.rb
class Item < ApplicationRecord

  def update_name_hiragana(force: false)
    rubyfuri = Rubyfuri::Client.new(ENV['YAHOO_JAPAN_DEVELOPER_CLIENT_ID'])
    update(name_hiragana: rubyfuri.furu(name))
  end
end
```

先ほど実装したメソッドを利用してitem.name_hiraganaに読み仮名を追加するためのrake taskを作ります。

```lib/tasks/item.rake
namespace :item do
  desc 'item.nameに読み仮名をつける'
  # rake item:name_hiragana
  task name_hiragana: :environment do
    Item.all.each {|item| item.update_name_hiragana}
  end
end
```

上記rake taskを実行します。

```console
> rake item:name_hiragana
```

`Item.pluck(:name_hiragana)` 等で読み仮名が格納されたことを確認して下さい。

### レスポンスのjson出力
ユーザーが入力したキーワード（フロント側から送られてきたキーワード）に従って、オートコンプリート候補となるレスポンスを返します。レスポンスの形式はなんでもいいです。jbuilderで書いてもいいのですが、意外と面倒。
なので、簡単にいい感じのJSONを返してくれるgemをつかいます。
[active_model_serializers](https://github.com/rails-api/active_model_serializers) が有名ですが、今回はactive_model_serializersと同様の使い勝手で、active_model_serializersよりも速い！とされる、 [fast_jsonapi](https://github.com/Netflix/fast_jsonapi) を使います。


まずgemをインストールします。
Gemfileに記述して

```Gemfile
gem 'fast_jsonapi' 
```

ふつーにbundle install。

```console
bundle install
```

fast_jsonapi でJSONを返すためには、 `include FastJsonapi::ObjectSerializer` したクラスを作り、出力したいカラムを `attributes :id, :name, :name_hiragana` のように書きます。これは、一番シンプルな例ですが、has_manyなどの関連でもできます。そちらをやりたい場合は公式のREADME読んでくださいませ。


```app/serializers/item_serializer.rb
class ItemSerializer
  include FastJsonapi::ObjectSerializer
  attributes :id, :name, :name_hiragana
end
```

上記は単純に単純にid, name, name_hiraganaを返します。

なお、返ってくるjsonはこんなかんじ。（この構造はフロント側のコードで使うので頭の片隅に置いておいてください。）

```json
{
  data: [
    {
      id: "167",
      type: "item",
      attributes: {
        id: 167,
        name: "トマト",
        name_hiragana: "とまと"
      }
    },
    {
      id: "168",
      type: "item",
      attributes: {
        id: 168,
        name: "きゅうり",
        name_hiragana: "きゅうり"
      }
    }
  ]
}
```

このjsonを返すコントローラのアクションはこちらです。フロント側から `params[:keyword]` として入力した文字列がやってきます。その文字列を検索して、結果をItemSerializerに渡してJSONで返します。 `render json: ItemSerializer.new(items)` 

```
class AjaxController < ApplicationController
  def item_autocomplete
    items = Item.ransack(name_or_name_hiragana_cont: params[:keyword]).result
    render json: ItemSerializer.new(items)
  end
end
```

### エンドポイント
オートコンプリートとは関係無いですが、複数のキーワードによる検索に対応できるようにします。
`split_queries(str)` がそれです。keywordが `,` で区切りだったら分割してransackに渡します。 `description_cont_all` とすることで、複数のキーワード（and検索）に対応しています。ransackの細かなところは [こちら](https://github.com/activerecord-hackery/ransack#search-matchers) を見ましょう

```app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
    @stores = Store.ransack(description_cont_all: split_queries(params[:keyword])).result
  end

  private

  def split_queries(str)
    return nil unless str
    str.split(',')
  end
end
```

エンドポイントをroutes.rbに書きます。これで `ajax/item_autocomplete` でアクセスできます。（js由来だけに制限したい！とかったら適宜コード書き換えましょう。今回は緩ーく書いています）

```config/routes.rb
Rails.application.routes.draw do
  root to: 'home#index'
  namespace :ajax do # このあたりを追加
    get :item_autocomplete
  end
end
```


## フロントエンド
### javascript環境のセットアップ
（rails 6なので最初からwebpacker入っています。rails 5系だったら 先にwebpackerを先にインストールしておいて下さい。）
#### jquery, selectizeのインストール

```
yarn add selectize
yarn add jquery
```

selectizeのcssを設定。（ application.css だったら　application.scss にリネームして下さい。）

ちなみに、bopotstrap3だと `node_moudles/selectize/dist/css/` を見てbootstrap3用を選択するといいかも。


```app/assets/stylesheets/application.scss
/*
 * This is a manifest file that'll be compiled into application.css, which will include all the files
 * listed below.
 *
 * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
 * vendor/assets/stylesheets directory can be referenced here using a relative path.
 *
 * You're free to add application-wide styles to this file and they'll appear at the bottom of the
 * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
 * files in this directory. Styles in this file should be added after the last require_* statement.
 * It is generally better to create a new file per style scope.
 *
 *= require_tree .
 *= require_self
 */

@import "selectize/dist/css/selectize.default";
```

### オートコンプリートの実装
ここら辺はrailsでseletize.jsの使うための手順。
sprocketにのっかって書いてもいいけど、ES6で書きたいのでwebpackerつかった実装にしました。
rails 5系で webpackerが入っていない場合はこちらの[Qiitaの記事 Rails & Webpackerでフロントエンド開発環境を整える](https://qiita.com/hiyamamoto/items/e0a30b4799314174b80f#webpacker%E3%82%92%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB)を参考に事前にインストールして下さい。

#### view
selectizeのターゲットとなるフォームにidを付与します。今回は、  `store_search` というidを追加しました。それぞれのプロジェクトであわせてください。
こちら後で説明しますが、javascriptを読み込むための `javascript_pack_tag` を書きます。今の段階でページを表示するとjavascriptがないのでエラーになります。

```app/views/home/index.html.erb
<%= javascript_pack_tag 'home' %>

<div>
  <%= form_with url: root_path, method: :get, local: true do |f| %>
    <%= f.text_field :keyword, value: params[:keyword], autocomplete: 'off', id: 'store_search' %>
    <%= f.submit '検索' %>
  <% end %>
</div>
```


#### javascript
ようやく最後。Javascriptを書きます！

webpackerのエントリーポイントはデフォルトは `app/javascript/packs` 。こちらにjavascript書きます。ファイル名は何でもいいのですが、viewとあわせるとわかりやすいので `home.js` にしました。
で、実際のコードは `app/javascript/home` 以下に書くのでここでは読み込むだけのコードを書きます。

```app/javascript/packs/home.js
require('home')
```

で、下記のコードが実行されます。ファイル名はindex.jsにしてください。（ `require('home')` だとデフォルトでindex.jsを読み込むので。）

```app/javascript/home/index.js
import $ from 'jquery';

require('selectize');

const url = (query) => {
  return `/ajax/item_autocomplete?keyword=${encodeURIComponent(query)}`
};

const items = (data) => {
  return data.map((item) => {
    return {
      text: item['attributes']['name'],
      sub_text: item['attributes']['name_hiragana'], // ひらがなでも検索結果を絞り込めるようにする
      label: item['attributes']['name'],
      value: item['attributes']['name']
    }
  })
};

const renderOption = (item, escape) => {
  return `<div><span>${escape(item.text ? item.text : '')}</span></div>`
};

const renderOptionCreate = (item, escape) => {
  return `<div><span>新規キーワード... </span><span>${escape(item.input) ? item.input : ''}</span></div>`
};

const getAutocomplete = (query, callback) => {
  $.ajax({
    url: url(query),
    type: 'GET',
    error: () => {
      callback()
    },
    success: (res) => {
      callback(items(res.data))
    }
  })
};

const addSelectize = () => {
  $('#store_search').selectize({
    searchField: ['text', 'sub_text'], // 入力値のフィルター対象、ひらがなでも検索結果を絞り込めるようにする
    labelField: 'label', // 表示させるラベル
    valueField: 'value', // inputのvalue
    closeAfterSelect: true,
    create: true, // 新規単語を追加を許可
    createOnBlur: true, // 画面外をタッチすると新規単語を追加
    render: {
      option: (item, escape) => {
        return renderOption(item, escape)
      },
      option_create: (item, escape) => {
        return renderOptionCreate(item, escape)
      }
    },
    load: (query, callback) => {
      if (!query.length) return callback();
      getAutocomplete(query, callback)
    }
  })
};

const initialize = function () {
  addSelectize();
};

window.onload = function () {
  initialize();
};
```

細かく解説します。


```javascript
import $ from 'jquery';

require('selectize');
```

ここで、ライブラリを読み込みます。raisl 6は最初からjqueryがないので、 jqueyをimportして、selectizeも読み込みます。


```javascript
const addSelectize = () => {
  $('#store_search').selectize({
    searchField: ['text', 'sub_text'], // 入力値のフィルター対象、ひらがなでも検索結果を絞り込めるようにする
    labelField: 'label', // 表示させるラベル
    valueField: 'value', // inputのvalue
    closeAfterSelect: true,
    create: true, // 新規単語を追加を許可
    createOnBlur: true, // 画面外をタッチすると新規単語を追加
    render: {
      option: (item, escape) => {
        return renderOption(item, escape)
      },
      option_create: (item, escape) => {
        return renderOptionCreate(item, escape)
      }
    },
    load: (query, callback) => {
      if (!query.length) return callback();
      getAutocomplete(query, callback)
    }
  })
};
```

ここら辺がselectizeの中心部。
`$('#store_search')` はjqueryのいつものやつ。 `.selectize` でselectizeを付与。

```javascript
load: (query, callback) => {
      if (!query.length) return callback();
      getAutocomplete(query, callback)
    }
```

ここで、キーワードが入力されたときに実行されます。ここで、 ajaxリクエスト送ります。

```javascript
const getAutocomplete = (query, callback) => {
  $.ajax({
    url: url(query),
    type: 'GET',
    error: () => {
      callback()
    },
    success: (res) => {
      callback(items(res.data))
    }
  })
};
```

みたままですが、queryがあったら下記のurlにリクエストを送ります。

```javascript
const url = (query) => {
  return `/ajax/item_autocomplete?keyword=${encodeURIComponent(query)}`
};
```

レスポンスは下記で処理。

```javascript
    success: (res) => {
      callback(items(res.data))
    }
```

レスポンスで得られたJSONを使いやすいようにマッピングし直します。

```javascript
const items = (data) => {
  return data.map((item) => {
    return {
      text: item['attributes']['name'],
      sub_text: item['attributes']['name_hiragana'], // ひらがなでも検索結果を絞り込めるようにする
      label: item['attributes']['name'],
      value: item['attributes']['name']
    }
  })
};
```

マッピングし直したkeyをここで指定します。意味の詳細は、[公式のusage](https://github.com/selectize/selectize.js/blob/master/docs/usage.md#data_searching) で調べましょう。簡易的には下記のようにコメント書きました。

```javascript
    searchField: ['text', 'sub_text'], // 入力値のフィルター対象、ひらがなでも検索結果を絞り込めるようにする
    labelField: 'label', // 表示させるラベル
    valueField: 'value', // inputのvalue
```

オートコンプリートリストおよび、フォームに表示される内容は下記で指定します。


```javascript
const renderOption = (item, escape) => {
  return `<div><span>${escape(item.text ? item.text : '')}</span></div>`
};

const renderOptionCreate = (item, escape) => {
  return `<div><span>新規キーワード... </span><span>${escape(item.input) ? item.input : ''}</span></div>`
};
```

既に記述していますが、このjavascriptを使いたいところで読み込むために
`<%= javascript_pack_tag 'home' %>` をviewに記述します。
今回は、 `app/views/home/index.html.erb` 記述しました。

最後、webpackerでjavascriptをアセットコンパイルしてもらいます。

```console
bin/webpack-dev-server                    
```

http://localhost:3000 にアクセスするとOK

![c0edcd5d09620e68158dfd6e8ebcd282.gif](https://qiita-image-store.s3.amazonaws.com/0/124445/c63728bf-1758-e96b-4d4c-063c77268b6f.gif)

以上。
長文読んでくれてありがとうです。