Rails7とJavaScriptでマルチセレクトかつオートコンプリート(サジェスト機能)を実装する 

# 実装デモ
![maruti-select-rails.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324819/1961990a-1284-9889-b689-267552bdba31.gif)


# 機能説明
予約に対して、メンバーを複数アサインする機能。
Reservationと、Memberが多対多でアソシエーションされている。
メンバーのアサインを複数、かつ、入力補完（検索）しながらインタラクティブに選択肢を表示させる。

リポジトリはこちら

https://github.com/kakeruAoyama/myapp

# 環境
```
$ ruby -v
=> ruby 3.1.2p20 (2022-04-12 revision 4491bb740a) [x86_64-linux]
$ rails -v
=> Rails 7.0.3.1
```
TailwindCSSをつかって実装していきます。

# アプリケーションの作成
```
$ rails new myapp --css tailwind
$ cd myapp
$ rails g scaffold Reservation title:string date:date 
$ rails db:migrate
```

rootを設定
```ruby:routes.rb
  root "reservations#index" #追記
```

サーバー起動
```
$ bin/dev
```
http://localhost:3000/

上記で新規投稿、一覧表示等ができることを確認。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324819/e159e8a5-7712-0dc9-26b4-5e70e577b297.png)

# Memberモデルと中間モデルの作成

```
$ rails g model Member name:string
$ rails g model Attendee member:references reservation:references
$ rails db:migrate
```


# アソシエーションの設定

```ruby:reservation.rb
class Reservation < ApplicationRecord
  has_many :attendees, dependent: :destroy
  has_many :attended_members, through: :attendees, source: :member
end
```

# seedの設定
メンバーの名前を入れる
今回はこちらのサイトから生成された名前を使用

https://namegen.jp/


<details><summary>長いので折りたたみ(クリックで表示)</summary>

```ruby:seed.rb
Member.create!([
  {name: "鈴木 綾子"},
  {name: "高橋 めぐみ"},
  {name: "田中 朋子"},
  {name: "鈴木 和美"},
  {name: "佐々木 真由美"},
  {name: "小林 真弓"},
  {name: "中村 千恵"},
  {name: "藤井 舞"},
  {name: "伊藤 千明"},
  {name: "松本 亜美"},
  {name: "鈴木 則子"},
  {name: "野口 瞳"},
  {name: "清水 弘子"},
  {name: "西田 由美"},
  {name: "増田 紀子"},
  {name: "中山 晶子"},
  {name: "関 恭子"},
  {name: "藤澤 恵"},
  {name: "斎藤 葵"},
  {name: "後藤 文子"},
  {name: "古田 仁美"},
  {name: "荒木 あかね"},
  {name: "渡辺 千代"},
  {name: "武田 絵梨"},
  {name: "星 博子"},
  {name: "松尾 麻実"},
  {name: "鷲尾 智子"},
  {name: "弓削 久美子"},
  {name: "野口 真貴子"},
  {name: "坂口 洋美	"},
  {name: "矢野 直樹"},
  {name: "佐藤 創"},
  {name: "伊藤 俊樹"},
  {name: "小林 勇介"},
  {name: "木下 祐介"},
  {name: "野田 祐介"},
  {name: "永井 真一"},
  {name: "長谷川 真一郎"},
  {name: "福島 渉"},
  {name: "堤 和彦"},
  {name: "古川 弘樹"},
  {name: "大西 昌弘"},
  {name: "杉浦 裕"},
  {name: "佐藤 洋二郎"},
  {name: "鈴木 昭徳"},
  {name: "野中 薫"},
  {name: "武内 浩二"},
  {name: "山本 暁"},
  {name: "村田 剛志"},
  {name: "佐藤 三千夫"},
  {name: "渡邉 竜"},
  {name: "高橋 寛司"},
  {name: "中村 紘一郎"},
  {name: "石原 剛史"},
  {name: "宮崎 崇史"},
  {name: "野口 優"},
  {name: "谷川 淳"},
  {name: "白石 雄大"},
  {name: "堀 正己"},
  {name: "山崎 広一	"},
])
```
</details>

```
$ rails db:seed
```

# 新規登録時にメンバーを選択させるセレクトボックスを表示させる

```erb:/views/reservations/_form.html.erb
  <div class="my-5">
    <select id="select" >
      <% Member.all.each do |m| %>
        <option value=<%= m.id%>><%= m.name %></option>
      <% end %>
    </select>
  </div>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324819/e3206f51-2ef0-d7e1-b638-30ebed672416.png)

# JSを読み込ませる
<b>※以下はRails7でのやり方です。Rials6以前をお使いの場合は、JSの読み込み方法が異なるので、気を付けてください。</b>

JSファイルを新規作成

```app/javascript/attendee.js
console.log("test")
```

下記を最後に追記
```ruby:config/importmap.rb
pin "attendee"
```

下記を最後に追加
```views/reservations/_form.html.erb
<%= javascript_import_module_tag "attendee" %>
```

再度、サーバーを立て直す
```
$ bin/dev
```

以下のように検証ページのconsoleに"test"が出てくればOK

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324819/59984538-a1c1-daba-3457-4f1ddc738495.png)

# 選択肢をカスタマイズ

ここが大きなコードになります。詳しくはコードのコメントを確認してください。

```js:attendee.js
// 大まかな流れ
// セレクトボックスの値から、idと名前をもってきて、クリックできるdivをつくる
// クリックすると、idをinputに追加する
// 同時に、表示用のdivにも名前を追加する
// 検索用のボックスを作成して、そこの入力を検知
// 入力された値により、セレクトボックスの値を検索してクリックできるdivを再生成
// 編集時には、セレクトされている状態にする

window.addEventListener('load', function() {
  const selectableOptions = document.getElementById("selectable-options")
  
  function addSelectableOptions(keyword){//選択肢を追加する関数
    while(selectableOptions.firstChild){
      selectableOptions.removeChild(selectableOptions.firstChild);
    }
    let opsions = document.getElementById("select").children
    let attendees_field = document.getElementById("attendees")
  
    for(let i=0; i < opsions.length; i++){
      let append_selectable_option
      append_selectable_option =  document.createElement("div")
      append_selectable_option.innerHTML = opsions[i].innerHTML //名前を要素に追加
      append_selectable_option.setAttribute('data-id', opsions[i].value)//IDをdata属性に追加

      append_selectable_option.onclick = function() {//クリックしたときに動く関数
        let selected_member_id = append_selectable_option.getAttribute('data-id')
        let selected_member_name = append_selectable_option.innerHTML
        if (append_selectable_option.classList.contains("is_selected")){//is_selectedが含まれていたら

          let selected_options_arry = attendees_field.value.split(",")// 選択されているIDを配列に入れて
          let removed_arry =  selected_options_arry.filter(e =>{
            return Number(e) != Number(selected_member_id)
          })//配列の中から自身のIDを削除
          attendees_field.value = removed_arry.join(",")// inputに追加

          let selected_names_arry = selected_member_names.innerHTML.split(",")// 選択されている名前を配列に入れて
          let removed_names_arry =  selected_names_arry.filter(e =>{
            return e != selected_member_name
          })//配列の中から自身の名前を削除
          selected_member_names.innerHTML = removed_names_arry.join(",")// inputに追加

        }else{
          attendees_field.value += selected_member_id + ","
          selected_member_names.innerHTML += selected_member_name + ","
        }
        append_selectable_option.classList.toggle('is_selected')
      };

      append_selectable_option.classList.add(
        "cursor-pointer",
        "hover:bg-gray-200", 
        "border-b-2", 
        "border-gray-200",
        "p-2",
      )

      if (attendees_field.value.split(",").includes(String(append_selectable_option.getAttribute('data-id')))){//既に選択されている場合は、is_selectedを追加
        append_selectable_option.classList.add("is_selected")
      }

      if (opsions[i].innerHTML.indexOf(keyword) != -1 ){//ユーザーが入力した値が含まれている要素のみを追加する
        selectableOptions.appendChild(append_selectable_option)
      }
    }
  }

  serch_box.addEventListener("input",function(){//ユーザーの入力ごとに実行される関数
    if (this.value != ""){
      addSelectableOptions(this.value)
    }
  });

  function setDefaultValueInEdit () {//編集時に値をセットする
    let attendees_field = document.getElementById("attendees")
    let opsions = document.getElementById("select").children
    let selected_options_arry = attendees_field.value.split(",")// 選択されているIDを配列に入れて
    for(let i=0; i < opsions.length; i++){
      if (selected_options_arry.includes(String(opsions[i].value))){
        selected_member_names.innerHTML += opsions[i].innerHTML + ","
      } 
    }
  }
  setDefaultValueInEdit()
})
```

ビューページも編集します。

```erb:_form.html.erb
  <div class="my-5">
    <div id="selected_member_names"></div>
    <input type="text" id="serch_box" placeholder="名前で検索" class="block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full">
    <div id="selectable-options"></div>

    <select id="select" class="hidden">
      <% Member.all.each do |m| %>
        <option value=<%= m.id%>><%= m.name %></option>
      <% end %>
    </select>

    <%= text_field_tag :attendees, @reservation.attendees.pluck(:member_id).join(",") + ",", class:"hidden" %>
  </div>
```

tailwindもカスタマイズします。

```css:assets/stylesheets/application.tailwind.css
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .is_selected {
    @apply bg-blue-200;
  }
}
```

turboがあるとうまくJSが動作しないのでオフにします

```js:app/javascript/application.js
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"

Turbo.session.drive = false;
```
以下のように表示されれば問題ありません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324819/c19da0c8-365c-ceab-0a2d-34a84517d066.png)


# データベースへ保存

入力された値は、パラメーターのattendeesにはいっているのでその値でデータベースへ保存してあげます。

```ruby:
  def create
    @reservation = Reservation.new(reservation_params)

    respond_to do |format|
      if @reservation.save
        format.html { redirect_to reservation_url(@reservation), notice: "Reservation was successfully created." }
        format.json { render :show, status: :created, location: @reservation }
        params[:attendees].split(",").each do |attendee|
          Attendee.create(member_id: attendee, reservation_id: @reservation.id)
        end
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @reservation.errors, status: :unprocessable_entity }
      end
    end
  end

# 省略

  def update
    respond_to do |format|
      if @reservation.update(reservation_params)
        format.html { redirect_to reservation_url(@reservation), notice: "Reservation was successfully updated." }
        format.json { render :show, status: :ok, location: @reservation }
        @reservation.attendees.destroy_all
        params[:attendees].split(",").each do |attendee|
          Attendee.create(member_id: attendee, reservation_id: @reservation.id)
        end
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @reservation.errors, status: :unprocessable_entity }
      end
    end
  end
```

# ビューページで参加者を表示

```erb:views/reservations/_reservation.html.erb
  <p class="my-5">
    <strong class="block font-medium mb-1">members:</strong>
    <% reservation.attended_members.each do |m| %>
      <%= m.name %>, 
    <% end %>
  </p>
```

以下のように投稿出来たら成功です
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324819/7119d15e-cfb0-5d40-7da3-d88c1f60de08.png)

以上で完成です。