🗓️

【Rails】フォームで過去の日付を選択できないようにする

2024/09/01に公開3

はじめに

現在作成中のアプリで、スケジュールを作成する際に、過去の日付を選択できないようにする実装を行いました。
保存するモデルのカラムはDate型を使用しており、form_withでフォームを作成しています。

環境

Ruby 3.2.3
Rails 7.1.3.4

実装内容(必要箇所のみ記載)

私のアプリでは旅程を立てるためにtripモデルのdeparture_datereturn_date(Date型)に保存します。
元のコード🔻

new.html.erb
<%= form_with(model: @trip, local: true) do |form| %>
  <%= form.date_field :departure_date, class: "form-input block w-full mt-2 p-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-base" %>
<% end %>

Image from Gyazo
現在は過去の日付も選択できるようになっています。
アプリでは旅の一週間前から通知を送りたいので、過去の日付けをユーザーが誤って登録しないように変更します。

new.html.erb(出発日のフォーム)
<%= form_with(model: @trip, local: true) do |form| %>
  <%= form.date_field :departure_date, class: "form-input block w-full mt-2 p-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-base", min: Date.current.strftime("%Y-%m-%d"), id: "departure_date" %>
<% end %>
  • Date.current.strftime("%Y-%m-%d"): Date.current を YYYY-MM-DD 形式に変換して、 min 属性に渡します。strftime("%Y-%m-%d") は、Ruby の Date や Time オブジェクトを特定の形式の文字列に変換するためのメソッド。
  • min 属性: HTML5の<input>要素の一部として使用され、特に日付や時間などの値を入力するフィールドで利用されます。この属性を指定することで、ユーザーが入力できる最小値を制限することができます。
    ブラウザはこのmin属性を検出すると、指定された最小値よりも前の日付を選択することを防ぐために、カレンダーで選択できる日付を自動的に制限します。
  • idは後ほどJSで使用します。

Date.currentとは?

Railsガイドによると、Active SupportではDate.currentを定義して現在のタイムゾーンにおける「今日」を定めています。
つまり、Date.todayはサーバーのシステムのタイムゾーンを使うので、ユーザーのタイムゾーンとズレが生じる(Date.todayがDate.yesterdayと等しくなる)可能性があります。
ユーザーの環境に合わせた日付の処理が必要な場合は、Date.currentを使うことが推奨されています。

詰まった点: 出発日より未来の日付を帰国日に選択する

出発日を選択した後に、その日付より未来の日付のみを帰国日として選べるようにするには、JavaScriptを使って出発日が選択された時に、その日付を基準に帰国日のmin属性を動的に設定する必要があると分かりました。
私はこの方法がすぐに思い浮かばず、難しく感じました。当初予想していたDate.futureはJavaScriptのライブラリには存在しないようです。

最終的なコード🔻

new.html.erb
<%= form_with(model: @trip, local: true) do |form| %>

      ~~~~~~省略~~~~~~~~~~~~

      <div class="flex flex-col md:flex-row justify-between mb-6 space-y-4 md:space-y-0 md:space-x-4">
        <div class="w-full">
          <%= form.label :departure_date, "出発日", class: "block text-gray-700 text-lg font-semibold mb-2" %>
          <%= form.date_field :departure_date, class: "form-input block w-full mt-2 p-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-base", min: Date.current.strftime("%Y-%m-%d"), id: "departure_date" %>
        </div>
        <div class="w-full">
          <%= form.label :return_date, "帰国日", class: "block text-gray-700 text-lg font-semibold mb-2" %>
          <%= form.date_field :return_date, class: "form-input block w-full mt-2 p-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-base", min: Date.current.strftime("%Y-%m-%d"), id: "return_date" %>
        </div>
      </div>

    <script>
      document.getElementById('departure_date').addEventListener('change', function() {
      const departureDate = new Date(this.value); #選択された出発日の日付をJavaScriptのDateオブジェクトに変換。この値を使って帰国日の最小選択日を設定します。this.valueは、変更された要素の値(選択された日付)を指す。
      const returnDateInput = document.getElementById('return_date'); #帰国日の日付入力フィールドを取得
      returnDateInput.min = this.value; // 出発日を基に最小日付を設定
      });
    </script>
<% end %>

出発日を選択すると、その日付を基準にして帰国日として選べる範囲が自動的に制限されるようになりました。
Image from Gyazo

終わりに

今回はビュー側で過去の日付を選択できないようにしましたが、コントローラーやモデルのバリデーションで保存できないようにする方法も考えられます。動的に日付の制限を行う際には、JavaScriptを使用することが重要であると改めて理解できました。

今回もご覧いただきありがとうございました。

参考

Discussion

Junichi ItoJunichi Ito

mockeyさん、こんにちは。

常に「出発日 <= 帰国日」になるようにするUI制御はプログラミングのお題としてなかなか興味深いですね。
ただ、erbの中にJSを埋め込むのはちょっとスマートさに欠けるので、Stimulusを使ってみるといいかも、と思いました。(Rails 7.0以降なら最初からStimulusが使えるようになってるはず)

作り方はこんな感じです。
まず、Stimulusのコントローラを作ります。

rails generate stimulus date-range

次にerbを修正してStimulusと連動できるようにします。

+<div data-controller="date-range">
   <div class="flex flex-col md:flex-row justify-between mb-6 space-y-4 md:space-y-0 md:space-x-4">
     <div class="w-full">
       <%= form.label :departure_date, "出発日", class: "block text-gray-700 text-lg font-semibold mb-2" %>
-      <%= form.date_field :departure_date, class: "form-input block w-full mt-2 p-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-base", min: Date.current.strftime("%Y-%m-%d"), id: "departure_date" %>
+      <%= form.date_field :departure_date, data: { date_range_target: 'from', action: 'change->date-range#updateTo' }, class: "form-input block w-full mt-2 p-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-base", min: Date.current.strftime("%Y-%m-%d") %>
     </div>
     <div class="w-full">
       <%= form.label :return_date, "帰国日", class: "block text-gray-700 text-lg font-semibold mb-2" %>
-      <%= form.date_field :return_date, class: "form-input block w-full mt-2 p-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-base", min: Date.current.strftime("%Y-%m-%d"), id: "return_date" %>
+      <%= form.date_field :return_date, data: { date_range_target: 'to' }, class: "form-input block w-full mt-2 p-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-base", min: Date.current.strftime("%Y-%m-%d") %>
     </div>
   </div>
+</div>

最後に、 app/javascript/controllers/date_range_controller.js を以下のように編集します。

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

// Connects to data-controller="date-range"
export default class extends Controller {
  static targets = ["from", "to"]

  updateTo() {
    // Fromが変更されたらToの最小値を更新する
    this.toTarget.min = this.fromTarget.value
  }
}

上のコードに出てきた date-range, from, to, updateTo はただの名前なので好きな名前を付けてもらって構いません。
これでこの記事と同じことが実現できるはずです。

さらに

updateToメソッドはこんなふうに実装するとさらにユーザーフレンドリーになると思います。

app/javascript/controllers/date_range_controller.js
  updateTo() {
    // FromがクリアされたらToもクリアする
    if (!this.fromTarget.value) {
      this.toTarget.min = new Date().toISOString().split('T')[0]
      this.toTarget.value = ""
      return
    }

    // Fromが変更されたらToの最小値を更新する
    this.toTarget.min = this.fromTarget.value

    // ToがFromを超えないようにする
    if (this.toTarget.value && this.toTarget.value < this.fromTarget.value) {
      this.toTarget.value = this.fromTarget.value
    }
  }

上のコードを実行するとこんな動きになります。

よかったら参考にしてみてください。

Junichi ItoJunichi Ito

おまけ

前のコメントの実装だとminの設定がerbとJSに分散するので、JSに一本化するのも良いかもしれません。

 <div data-controller="date-range">
   <div class="flex flex-col md:flex-row justify-between mb-6 space-y-4 md:space-y-0 md:space-x-4">
     <div class="w-full">
       <%= form.label :departure_date, "出発日", class: "block text-gray-700 text-lg font-semibold mb-2" %>
-      <%= form.date_field :departure_date, data: { date_range_target: 'from', action: 'change->date-range#updateTo' }, class: "form-input block w-full mt-2 p-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-base", min: Date.current.strftime("%Y-%m-%d") %>
+      <%= form.date_field :departure_date, data: { date_range_target: 'from', action: 'change->date-range#updateTo' }, class: "form-input block w-full mt-2 p-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-base" %>
     </div>
     <div class="w-full">
       <%= form.label :return_date, "帰国日", class: "block text-gray-700 text-lg font-semibold mb-2" %>
-      <%= form.date_field :return_date, data: { date_range_target: 'to' }, class: "form-input block w-full mt-2 p-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-base", min: Date.current.strftime("%Y-%m-%d") %>
+      <%= form.date_field :return_date, data: { date_range_target: 'to' }, class: "form-input block w-full mt-2 p-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-base" %>
     </div>
   </div>
 </div>
app/javascript/controllers/date_range_controller.js
 import { Controller } from "@hotwired/stimulus"
 
 // Connects to data-controller="date-range"
 export default class extends Controller {
   static targets = ["from", "to"]
+
+  connect() {
+    // Stimulusに接続したタイミングでminを設定
+    this.fromTarget.min = this.sysdate
+    this.toTarget.min = this.sysdate
+  }
 
   updateTo() {
     // FromがクリアされたらToもクリアする
     if (!this.fromTarget.value) {
-      this.toTarget.min = new Date().toISOString().split('T')[0]
+      this.toTarget.min = this.sysdate
       this.toTarget.value = ""
       return
     }
 
     // Fromが変更されたらToの最小値を更新する
     this.toTarget.min = this.fromTarget.value
 
     // ToがFromを超えないようにする
     if (this.toTarget.value && this.toTarget.value < this.fromTarget.value) {
       this.toTarget.value = this.fromTarget.value
     }
   }
+
+  get sysdate() {
+    return new Date().toISOString().split('T')[0]
+  }
 }
mockeymockey

伊藤さん、こんばんは。

昨日はご丁寧にコメントを頂きましてありがとうございました。
ご提案してくださったコードを実行したところ、同じ挙動になったことを確認できました。
自分ではstimulusで実装できると考えられていなかったので、大変参考になりました。
また、ユーザビリティを考慮して削除機能を連動させる点も今後の開発で意識していきます。
Ruby入門の本🍒で現在学習中なので、引き続き学習を頑張ります。

ありがとうございました。