Choices.js + fetchAPIでフィルタ付き動的セレクトボックス [脱jQuery]

## 概要
件名のモノが必要になった時
ググって出てきたのはajaxやcoffee script、select2にchosenと
内容が古かったり環境制約で使えないものだったりで苦しめられたので
rails6とpure javascriptで動くサンプルを遺します↓

***

親カテゴリの選択に応じて動的に子の選択肢をセットする
![Screen Shot 2020-02-09 at 2.18.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/582041/81e9dcdb-c55b-fbd6-37b9-6b6b8484bd02.png)

*** 

フィルタ検索機能
![Screen Shot 2020-02-09 at 4.01.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/582041/6e579919-dd29-9ddc-9ba6-f1c5b9a86c49.png)

***

#### [Sample app source code](https://github.com/nururi/filterable-categorized-selectbox)
* Ruby 2.7.0
* Rails 6.0.2.1
* Choices.js
* Fetch API

***

#### [Choices.js](https://github.com/jshjohnson/Choices)
素のjavascriptで書かれた軽量custom select box
jQueryに依存せずにselect2やchosenのようなフィルタ機能を実現します
option設定やメソッドの詳細はGitHubで確認出来ます
[公式動作デモ](https://joshuajohnson.co.uk/Choices)

***

#### [Fetch API](https://github.com/whatwg/fetch)
Webブラウザの~~新しめの~~標準APIで、非同期通信処理をカンタンに書けます
従来のjQuery.ajax、XHR(XMLHttpRequest)を代替するとかしないとか。

***

# How does it work?
大まかな処理の流れは以下になります

1. ユーザが親select boxからカテゴリを選択する
2. 親select boxのchangeイベントが発火
3. javascriptがfetchでserverのtile一覧apiを叩く
4. serverがtile一覧をjsonで返す
5. javascriptがjsonを受け取る
6. javascriptがchoicesメソッドを経由して子select boxにjsonの内容をセットする

***

以下具体的なコードです

***

### javascript
- `/javascript/tiles.js`

```js
document.addEventListener('DOMContentLoaded', function() {
  /* apply choices to select boxes */
  const choicesOptions = {
    shouldSort: false,
    removeItemButton: true,
    searchResultLimit: 9,   // default: 4
    searchFields: ['label'] // default: ['label', 'value']
  };
  const selects = document.querySelectorAll('select');
  selects.forEach(function(select) {
    select.choices = new Choices(select, choicesOptions);
  });

  /* add event listener to first select */
  const firstSelect = document.getElementById('first_select')
  if (firstSelect != null) {
    firstSelect.addEventListener('change', setSecondChoices); // type: 'input' is not effective.
  }

  /* get tiles json by fetch api */
  async function getTiles(tile_category_id) {
    const params = new URLSearchParams();
    params.set('tile_category_id', tile_category_id);
    const apiURL = `/api/tiles?${params}`;
    const response = await fetch(apiURL);
    const json = await response.json();
    return json;
  }

  /* set values to second select */
  function setSecondChoices() {
    const secondSelect = document.getElementById('second_select');
    const choices = secondSelect.choices;
    /* clear current values & selected item */
    choices.clearStore();
    const tile_category_id = firstSelect.value;
    getTiles(tile_category_id).then(json => {
      // setChoices(choices, value, label, replaceChoices);
      choices.setChoices(json, 'index', 'display_name', true);
    });
  }
});
```

***

要点を抜粋解説します

序盤の以下の部分で素のselect要素に対してchoicesを適用しています

```js
const selects = document.querySelectorAll('select');
selects.forEach(function(select) {
  select.choices = new Choices(select, choicesOptions);
});
```

***

choices適用後はカスタムタグが挿入されて見た目が変わり、フィルタが使えるようになります
select boxに対する項目の追加や削除はchoicesメソッドを通す必要があるので注意して下さい

***

続くaddEventListenerで親select boxのchangeイベントを拾い、setSecondChoices()を呼びます

```js
firstSelect.addEventListener('change', setSecondChoices);
```

***

setSecondChoices()内
choices適用時に`select.choices = new Choices...`という風に書いたので
以下のようにselect boxのchoicesにアクセス出来ます

```js
const choices = secondSelect.choices;
```

***

親カテゴリが変更された時、変更前に選んだ子が残っていると宜しくないのでクリアします

```js
choices.clearStore();
```

***

getTiles()は非同期処理で`/api/tiles?`を叩きます
async/await+fetchAPIを使用します

```js
/* get tiles json by fetch api */
async function getTiles(tile_category_id) {
  const params = new URLSearchParams();
  params.set('tile_category_id', tile_category_id);
  const apiURL = `/api/tiles?${params}`;
  const response = await fetch(apiURL);
  const json = await response.json();
  return json;
}
```

***

ES6縛りでasync/await記法が使えない場合は以下をお使い下さい

```js
/* set values to second select */
function setSecondChoices() {
  const secondSelect = document.getElementById('second_select');
  const choices = secondSelect.choices;
  /* clear current values & selected item */
  choices.clearStore();
  const tile_category_id = firstSelect.value;
  const params = new URLSearchParams();
  params.set('tile_category_id', tile_category_id);
  const url = `/api/tiles?${params}`;
  /* get tiles JSON by fetch api */
  fetch(url).then(function(response) {
    return response.json();
  }).then(function(json) {
    // setChoices(choices, value, label, replaceChoices);
    choices.setChoices(json, 'index', 'display_name', true);
  });
}
```

***

apiを叩いて返ってくるjsonの構造は以下のような形式です

```js
[
  {"display_name":"白","index":41},
  {"display_name":"發","index":42},
  {"display_name":"中","index":43}
]
```

***

setChoices()の第二, 第三引数で
choicesのvalueとlabelに対応するjson中のhashのkeyを指定します(value, labelの順)

```js
// setChoices(choices, value, label, replaceChoices);
choices.setChoices(json, 'index', 'display_name', true);
```

これで子select boxの選択肢が書き換わります

***

- `/javascript/packs/application.js`  
webpackerにpackしてもらう為、↑のtiles.jsをimportしてるだけ

```js
import '../tiles.js'
```

***

### routes.rb
* select boxesを表示: `/` (root)
* カテゴリ毎のtile一覧を取得するapi: `/api/tiles?tile_category_id=xxx`

```ruby
Rails.application.routes.draw do
  root controller: :top_page, action: :show
  namespace :api do
    resources :tiles, only: [:index]
  end
end
```

***

### model
Model定義と一覧取得は今回の本筋ではないのでhashでベタ書きしました (DB不要です)
RailsユーザならActiveRecordに置き換えるのは造作も無い事でしょう
indexは各牌識別用の通し番号です
rubyのsymbolって漢字使えたんですね…:scream_cat:

***

- `/models/concerns/tiles_identifiable.rb`

```ruby
module TilesIdentifiable
  extend ActiveSupport::Concern

  included do
    def get_tiles(tile_category_id:)
      tile_category = tile_categories.key(tile_category_id)
      case tile_category
      when :萬子 then characters
      when :筒子 then dots
      when :索子 then bamboos
      when :風牌 then winds
      when :三元牌 then dragons
      else all_tiles
      end
    end

    def tile_categories
      {
        萬子: 0,
        筒子: 1,
        索子: 2,
        風牌: 3,
        三元牌: 4,
      }
    end

    def numbers
      1..9
    end

    def chinese_numerals
      ['一', '二', '三', '四', '五', '六', '七', '八', '九']
    end

    def to_chinese_numerals(number)
      chinese_numerals[number - 1]
    end

    # tile: { display_name:, index: }
    def characters(base_index: 10 * 0)
      numbers.map { | number | { display_name: "#{to_chinese_numerals(number)}萬", index: base_index + number } }
    end

    def dots(base_index: 10 * 1)
      numbers.map { | number | { display_name: "#{to_chinese_numerals(number)}筒", index: base_index + number } }
    end

    def bamboos(base_index: 10 * 2)
      numbers.map { | number | { display_name: "#{to_chinese_numerals(number)}索", index: base_index + number } }
    end

    def winds(base_index: 10 * 3)
      ['東', '南', '西', '北'].map.with_index(1) {
        | wind, index | { display_name: wind, index: base_index + index } }
    end

    def dragons(base_index: 10 * 4)
      ['白', '發', '中'].map.with_index(1) {
        | dragon, index | { display_name: dragon, index: base_index + index } }
    end

    def all_tiles
      characters | dots | bamboos | winds | dragons
    end
  end
end
```

***

### controller
- `controllers/top_page_controller.rb`

```ruby
class TopPageController < ApplicationController
  include TilesIdentifiable
  before_action :set_values

  private
    def set_values
      @first_choices = tile_categories.to_a
    end
end
```

***

- `controllers/api/tiles_controller.rb`  
指定されたカテゴリに対応する牌一覧をjsonで返すapi

```ruby
class Api::TilesController < ApplicationController
  include TilesIdentifiable

  def index
    tile_category_id = params[:tile_category_id]
    if tile_category_id.blank?
      render json: all_tiles
    else
      render json: get_tiles(tile_category_id: tile_category_id.to_i)
    end
  end
end
```

***

### view
素のerbです

- `views/top_page/show.html.erb`

```html
<div class='form_wrapper'>
  <%= form_with do | form | %>
    <%= form.label 'tile categories' %>
    <%= form.select :first_select, @first_choices, { include_blank: true }, {} %>
    <%= form.label 'tiles' %>
    <%= form.select :second_select, [], { include_blank: true }, {} %>
  <% end %>
</div>
```

***

- `views/layouts/application.html.erb`  
choicesのjsとcss、whatwg-fetchのjsをCDNでお手軽導入

```html
<head>
  ...
  <!-- Include Choices CSS -->
  <link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/choices.js/public/assets/styles/choices.min.css'/>
  <!-- Include Choices JavaScript (latest) -->
  <script src='https://cdn.jsdelivr.net/npm/choices.js/public/assets/scripts/choices.min.js'></script>
  <!-- Include whatwg-fetch -->
  <script src='https://cdn.jsdelivr.net/npm/whatwg-fetch@3.0.0/dist/fetch.umd.min.js'></script>
</head>
```

***

## 最後に
誰かの何らかの助けになれば幸いです
フィルタの日本語動作の確認で漢字を使う麻雀牌(Tile)にしてみたけど
牌一覧用意するのが無駄に面倒でもうやらないと思います:dizzy_face: