Rails + Stimulus で動的なフォームの選択肢を実装する

この記事では、RailsでStimulusを使用して、スタッフを選択すると関連する利用可能な時間帯のプルダウンが更新される機能を実装する手順をご紹介します。

## 目的

予約システムにおいて、ユーザーがスタッフを選ぶと、そのスタッフの利用可能な時間帯がプルダウンで選択できるようにします。これには、次の技術を使用します。

- **Rails**: アプリケーションのバックエンドフレームワーク
- **Stimulus**: フロントエンドのJavaScriptフレームワーク

## ステップ 1: フォームの準備


### 予約フォームのセットアップ

まず、以下のコードを使って、予約に必要な情報を取得するためのフォームを作成します。

```erb
<h1><%= @reservation.customer.name %>様の予約</h1>
<h2>予約内容</h2>
希望開始日時: <div id= reservation-date><%= @reservation.date.strftime('%Y-%m-%d') %> <%= @reservation.time_slot %>
<br>

<%= form_with url: time_tables_path, method: :post, local: true, data: { controller: "staff-selector" } do |form| %>
  <% @reservation_services.each_with_index do |reservation_service, index| %>
    <!-- サービス名の表示 -->
    <%= reservation_service.service.name %>
    <!-- サービスの所要時間の表示 -->
    <%= format_duration(reservation_service.service.duration) %>
    <!-- スタッフ選択のためのプルダウン -->
    <%= form.label :staff_id, 'スタッフ選択' %>
    <%= form.collection_select :staff_id, @working_staffs, :id, :name, { include_blank: true }, { id: "staff_select_#{index}", data: { staff_selector_target: "staff", action: "change->staff-selector#updateSlots", slots_target_id: "time_slots_select_#{index}" } } %>
    <!-- 利用可能な時間帯選択のためのプルダウン -->
    <%= form.label :available_slots, '利用可能な時間帯' %>
    <%= form.select :time_slot, [], { include_blank: true }, { id: "time_slots_select_#{index}", data: { staff_selector_target: "slots" } } %>
    <br>
  <% end %>
  <%= form.submit "予定を確認" %>
<% end %>

<script type="text/javascript">
  // ERBを使用してRailsのインスタンス変数をJavaScript変数にセット
  var reservationDate = "<%= @reservation.date.strftime('%Y-%m-%d') %>";
</script>

```

このコードで実現されているのは以下のことです：

- `form_with` ヘルパーで、フォームの送信先（`url: time_tables_path`）とHTTPメソッド（`method: :post`）を設定しています。
- `data: { controller: "staff-selector" }` は、このフォームが `staff-selector` コントローラによって制御されることをStimulusに伝えます。
- `@reservation_services.each_with_index` で、複数の予約サービスがある場合にそれぞれのサービスについてフォームのフィールドを生成しています。
- 各スタッフ選択プルダウンには、一意の `id` が与えられており、`data-staff-selector-target="staff"` 属性を使用してStimulusコントローラの `staff` ターゲットとしてマークされています。
- 各利用可能な時間帯プルダウンにも一意の `id` があり、`data-staff-selector-target="slots"` を使って `slots` ターゲットとしてマークされています。
- スタッフ選択プルダウンの変更を検知するために、`action: "change->staff-selector#updateSlots"` 属性が設定されています。これにより、プルダウンの選択が変更された際に `updateSlots` メソッドが呼び出されます。

## ステップ 2: Stimulus コントローラの設定

フォームのプルダウンが正しく配置されたら、次にStimulusを用いて、スタッフの選択に応じて利用可能な時間帯のプルダウンを動的に更新するJavaScriptコントローラを設定します。

### staff-selector コントローラの作成

Stimulusを使用して動的な更新を行うためには、まずStimulusコントローラを作成し、必要なターゲットとアクションを定義します。

```javascript
// app/javascript/controllers/staff_selector_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["staff", "slots"]

  connect() {
    console.log('StaffSelectorController connected'); // コントローラが接続されたことを確認
  }

  updateSlots(event) {
    // スタッフ選択のプルダウンからIDを取得し、対応する時間帯のプルダウンのIDを構築
    const staffSelectElement = event.target;
    const slotsSelectId = staffSelectElement.dataset.slotsTargetId;
    const staffId = staffSelectElement.value;

    // 対応するslotsセレクト要素を取得
    const slotsSelect = document.getElementById(slotsSelectId);
    if (!slotsSelect) {
      console.error(`No slots select element found with ID: ${slotsSelectId}`);
      return;
    }

    // 予約日とスタッフIDを用いて、サーバーから利用可能な時間帯を取得
    fetch(`/schedules/available_slots?staff_id=${staffId}&date=${encodeURIComponent(reservationDate)}`)
      .then(response => response.json())
      .then(data => {
        // 取得した時間帯でプルダウンを更新
        slotsSelect.innerHTML = '';
        data.forEach(time => {
          slotsSelect.options.add(new Option(time, time));
        });
      })
      .catch(error => {
        console.error("Error fetching available slots:", error);
      });
  }
}
```

### 補足1

- `static targets` でStimulusコントローラに `staff` と `slots` の2つのターゲットを宣言しています。
- `connect` メソッドは、コントローラがページに接続された時に実行され、コンソールログに接続を確認するためのメッセージを出力しています。
- `updateSlots` メソッドは、スタッフの選択が変更された時にトリガーされ（updateSlotsはステップ1のサンプルコードで定義している）、新しいスタッフIDと予約日に基づいてサーバーから利用可能な時間帯を取得し、関連する `slots` ターゲットの選択肢を更新します。

### 補足2
- Stimulusフレームワークでは「ターゲット」とは、JavaScriptのコントローラがHTML要素を直接参照するための仕組みを指します。ターゲットを宣言することで、HTML内の特定の要素に直接アクセスし、それに対してJavaScriptで操作を行うことができるようになります。
- `updateSlots(event)` メソッドの各行が何をしているのか、順を追って説明します。

    1. **event:**
   `event`は、イベントを扱う際にブラウザが提供するオブジェクトです。イベントとは、ユーザが何かしらの操作を行った時に発生するもの（例えば、クリックやフォームの値の変更など）です。
   2. **event.target:**
   `event.target`は、イベントが発生したDOM要素を指します。このケースでは、スタッフ選択の`select`要素が該当します。
   3. **staffSelectElement.dataset.slotsTargetId:**
   `dataset`はHTML要素の`data-*`属性にアクセスするためのものです。`slotsTargetId`は、HTMLで定義されたカスタムデータ属性`data-slots-target-id`の値を取得しています。
   4. **staffSelectElement.value:**
   `value`プロパティは、フォーム要素（この場合は`select`要素）の現在の値を取得します。
   5. **document.getElementById(slotsSelectId):**
   `getElementById`は、指定したID属性を持つDOM要素を返します。ここでは、選択されたスタッフIDに基づいて動的にIDを構築し、それに対応する時間帯の`select`要素を取得しています。
   6. **fetch:**
   `fetch`は、サーバーにHTTPリクエストを非同期で行うためのメソッドです。ここでは、選択されたスタッフIDと日付をサーバーに送り、利用可能な時間帯のデータを取得しています。
   7. **encodeURIComponent(reservationDate):**
   `encodeURIComponent`は、URI（統一リソース識別子）の一部として使用できるように文字列をエスケープするための関数です。予約日の日付をクエリパラメータとしてURLに含めるために使用しています。
   8. **response.json():**
   `response.json()`は、レスポンスボディをJSONとして解析し、その結果をPromiseとして返すメソッドです。
   9. **data.forEach(...):**
   `forEach`は、配列の各要素に対して指定した処理を実行するメソッドです。サーバーから受け取った時間帯（`data`配列）をループし、各時間帯に対して新しい`Option`要素を作成してプルダウンに追加しています。
   10. **slotsSelect.innerHTML:**
    `innerHTML`プロパティを空文字に設定することで、`select`要素の中の既存の選択肢をすべてクリアしています。

    このコードの流れは、スタッフが選択されると、そのスタッフIDに基づいて利用可能な時間帯をサーバーから取得し、取得したデータを使って関連する時間帯のプルダウンの選択肢を更新する、というものです。


## ステップ 3: バックエンドのアクションを実装する

フォームのスタッフプルダウンが選択されたとき、`staff_selector`コントローラの`updateSlots`メソッドは非同期でサーバーにリクエストを送信し、選択されたスタッフに利用可能な時間帯を問い合わせます。この処理を行うために、Railsバックエンドに次のアクションを実装する必要があります。

### schedules_controller.rb の更新

利用可能な時間帯を計算して返すための `available_slots` アクションを`SchedulesController`に追加します。このアクションは、クライアントからの非同期リクエストに応答して、選択されたスタッフの利用可能な時間帯のリストをJSON形式で返します。

```ruby
# app/controllers/schedules_controller.rb
class SchedulesController < ApplicationController
  # 省略...

  def available_slots
    date = params[:date].present? ? Date.parse(params[:date]) : Date.today
    staff_id = params[:staff_id]
    staff_schedule = Schedule.find_by(date: date, staff_id: staff_id)

    if staff_schedule
      # スタッフのスケジュールから利用可能な時間帯を計算するメソッド
      @available_times = calculate_available_times(staff_schedule)
      render json: @available_times
    else
      render json: []
    end
  end

  private

  def calculate_available_times(schedule)
    # スケジュールから利用可能な時間帯を計算して返すロジック
    # 省略...
  end
end
```

このコードで行っていることは以下の通りです：

- パラメータから日付とスタッフIDを取得しています。
- `find_by`メソッドを使用して、特定の日付とスタッフIDに対応するスケジュールを検索しています。
- スケジュールが見つかれば、`calculate_available_times`メソッドを呼び出して、そのスケジュールに基づいて利用可能な時間帯を計算します。
- 最後に、計算された時間帯をJSON形式でフロントエンドに返します。

このアクションは、Stimulusコントローラから非同期で呼び出されることで、ユーザーがスタッフを選択したときに動的に時間帯の選択肢を更新するためのデータを提供します。

---

このステップで解説したバックエンドの実装が完了すれば、次はこの機能をテストし、動作を確認するための手順に進むことができます。ご確認ください。