LoginSignup
8
6

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

Last updated at Posted at 2022-08-02

実装デモ

maruti-select-rails.gif

機能説明

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

リポジトリはこちら

環境

$ 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を設定

routes.rb
  root "reservations#index" #追記

サーバー起動

$ bin/dev

上記で新規投稿、一覧表示等ができることを確認。
image.png

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

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

アソシエーションの設定

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

seedの設定

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

長いので折りたたみ(クリックで表示)
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: "山崎 広一	"},
])
$ rails db:seed

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

/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

JSを読み込ませる

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

JSファイルを新規作成

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

下記を最後に追記

config/importmap.rb
pin "attendee"

下記を最後に追加

views/reservations/_form.html.erb
<%= javascript_import_module_tag "attendee" %>

再度、サーバーを立て直す

$ bin/dev

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

image.png

選択肢をカスタマイズ

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

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()
})

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

_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もカスタマイズします。

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

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

turboがあるとうまく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

データベースへ保存

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

  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

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

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

以上で完成です。

8
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
6