diff --git a/app/helpers/date_range_helper.rb b/app/helpers/date_range_helper.rb index cc73af80d8..4327f6cdb7 100644 --- a/app/helpers/date_range_helper.rb +++ b/app/helpers/date_range_helper.rb @@ -37,7 +37,10 @@ def selected_interval date_range_params.split(" - ").map do |d| Date.strptime(d, "%B %d, %Y") rescue - raise "Invalid date: #{d} in #{date_range_params}" + flash.now[:notice] = "Invalid Date range provided. Reset to default date range" + return default_date.split(" - ").map do |d| + Date.strptime(d.to_s, "%B %d, %Y") + end end end diff --git a/app/javascript/application.js b/app/javascript/application.js index 8f2ff3a980..b37af4a96b 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -49,6 +49,10 @@ toastr.options = { "timeOut": "1400" } +// This global variable tracks whether Litepicker is actively managing the date range input field. +// It prevents custom validation logic from interfering when Litepicker is in use. +window.isLitepickerActive = false; + function isMobileResolution() { return $(window).width() < 992; } @@ -58,57 +62,94 @@ function isShortHeightScreen() { } $(document).ready(function(){ - const hash = window.location.hash; - if (hash) { - $('ul.nav a[href="' + hash + '"]').tab('show'); - } - const isMobile = isMobileResolution(); - const isShortHeight = isShortHeightScreen(); - - const calendarElement = document.getElementById('calendar'); - if (calendarElement) { - new Calendar(calendarElement, { - timeZone: 'UTC', - firstDay: 1, - plugins: [luxonPlugin, dayGridPlugin, listPlugin], - displayEventTime: true, - eventLimit: true, - events: 'schedule.json', - height: isMobile || isShortHeight ? 'auto' : 'parent', - defaultView: isMobile ? 'listWeek' : 'month' - }).render(); - } - - const rangeElement = document.getElementById("filters_date_range"); - if (!rangeElement) { - return; - } - const today = DateTime.now(); - const startDate = new Date(rangeElement.dataset["initialStartDate"]); - const endDate = new Date(rangeElement.dataset["initialEndDate"]); - - const picker = new Litepicker({ - element: rangeElement, - plugins: ['ranges'], - startDate: startDate, - endDate: endDate, - format: "MMMM D, YYYY", - ranges: { - customRanges: { - 'Default': [today.minus({'months': 2}).toJSDate(), today.plus({'months': 1}).toJSDate()], - 'All Time': [today.minus({ 'years': 100 }).toJSDate(), today.plus({ 'years': 1 }).toJSDate()], - 'Today': [today.toJSDate(), today.toJSDate()], - 'Yesterday': [today.minus({'days': 1}).toJSDate(), today.minus({'days': 1}).toJSDate()], - 'Last 7 Days': [today.minus({'days': 6}).toJSDate(), today.toJSDate()], - 'Last 30 Days': [today.minus({'days': 29}).toJSDate(), today.toJSDate()], - 'This Month': [today.startOf('month').toJSDate(), today.endOf('month').toJSDate()], - 'Last Month': [today.minus({'months': 1}).startOf('month').toJSDate(), - today.minus({'month': 1}).endOf('month').toJSDate()], - 'Last 12 Months': [today.minus({'months': 12}).plus({'days': 1}).toJSDate(), today.toJSDate()], - 'Prior Year': [today.startOf('year').minus({'years': 1}).toJSDate(), today.minus({'year': 1}).endOf('year').toJSDate()], - 'This Year': [today.startOf('year').toJSDate(), today.endOf('year').toJSDate()], - } - } - }); - picker.setDateRange(startDate, endDate); + const hash = window.location.hash; + if (hash) { + $('ul.nav a[href="' + hash + '"]').tab("show"); + } + const isMobile = isMobileResolution(); + const isShortHeight = isShortHeightScreen(); + + const calendarElement = document.getElementById("calendar"); + if (calendarElement) { + new Calendar(calendarElement, { + timeZone: "UTC", + firstDay: 1, + plugins: [luxonPlugin, dayGridPlugin, listPlugin], + displayEventTime: true, + eventLimit: true, + events: "schedule.json", + height: isMobile || isShortHeight ? "auto" : "parent", + defaultView: isMobile ? "listWeek" : "month", + }).render(); + } + + const rangeElement = document.getElementById("filters_date_range"); + if (!rangeElement) { + return; + } + const today = DateTime.now(); + const startDate = new Date(rangeElement.dataset["initialStartDate"]); + const endDate = new Date(rangeElement.dataset["initialEndDate"]); + + const picker = new Litepicker({ + element: rangeElement, + plugins: ["ranges"], + startDate: startDate, + endDate: endDate, + format: "MMMM D, YYYY", + ranges: { + customRanges: { + Default: [ + today.minus({ months: 2 }).toJSDate(), + today.plus({ months: 1 }).toJSDate(), + ], + "All Time": [ + today.minus({ years: 100 }).toJSDate(), + today.plus({ years: 1 }).toJSDate(), + ], + Today: [today.toJSDate(), today.toJSDate()], + Yesterday: [ + today.minus({ days: 1 }).toJSDate(), + today.minus({ days: 1 }).toJSDate(), + ], + "Last 7 Days": [today.minus({ days: 6 }).toJSDate(), today.toJSDate()], + "Last 30 Days": [ + today.minus({ days: 29 }).toJSDate(), + today.toJSDate(), + ], + "This Month": [ + today.startOf("month").toJSDate(), + today.endOf("month").toJSDate(), + ], + "Last Month": [ + today.minus({ months: 1 }).startOf("month").toJSDate(), + today.minus({ month: 1 }).endOf("month").toJSDate(), + ], + "Last 12 Months": [ + today.minus({ months: 12 }).plus({ days: 1 }).toJSDate(), + today.toJSDate(), + ], + "Prior Year": [ + today.startOf("year").minus({ years: 1 }).toJSDate(), + today.minus({ year: 1 }).endOf("year").toJSDate(), + ], + "This Year": [ + today.startOf("year").toJSDate(), + today.endOf("year").toJSDate(), + ], + }, + }, + }); + + // litepicker docs aren't clear on how to register events + // https://github.com/wakirin/Litepicker/issues/301 + picker.on("show", () => { + window.isLitepickerActive = true; + }); + + picker.on("hide", () => { + window.isLitepickerActive = false; + }); + + picker.setDateRange(startDate, endDate); }); diff --git a/app/javascript/controllers/date_range_controller.js b/app/javascript/controllers/date_range_controller.js new file mode 100644 index 0000000000..ba41c062b6 --- /dev/null +++ b/app/javascript/controllers/date_range_controller.js @@ -0,0 +1,53 @@ +// This Stimulus controller is used to handle custom validation for the date range input field. +// Litepicker.js manages the date range field and prevents invalid data when users interact with its calendar control. +// However, if a user tabs into the field and enters invalid data without triggering Litepicker events, +// Litepicker won't validate the input, leaving invalid data in the field. +// This controller ensures that in such cases, custom validation is performed to alert the user about invalid input. +// +// Note: The `data-skip-validation` attribute is used only in automated system tests to disable client-side validation. +// In real user interactions, if a user enters an invalid date and immediately hits Enter, the form submits before +// JS blur-based validation runs, so server-side validation is exercised as expected. +// However, in system tests (especially on CI), the JS blur validation always runs before form submission, +// making it impossible to test server-side validation for this scenario unless client-side validation is disabled. +// This attribute should only be set in test code. + +import { Controller } from "@hotwired/stimulus"; +import { DateTime } from "luxon"; + +export default class extends Controller { + static targets = ["input"]; + + connect() { + this.initialStart = this.inputTarget.dataset.initialStartDate; + this.initialEnd = this.inputTarget.dataset.initialEndDate; + this.format = "MMMM d, yyyy"; + } + + validate(event) { + event.preventDefault(); + + if (this.inputTarget.dataset.skipValidation === "true" || window.isLitepickerActive) { + return; + } + + const value = this.inputTarget.value.trim(); + const [startStr, endStr] = value.split(" - ").map((s) => s.trim()); + + const isValid = this.isValidDateRange(startStr, endStr); + + if (!isValid) { + alert("Please enter a valid date range (e.g., January 1, 2024 - March 15, 2024).") + } + } + + isValidDateRange(startStr, endStr) { + try { + const start = DateTime.fromFormat(startStr, this.format); + const end = DateTime.fromFormat(endStr, this.format); + + return start.isValid && end.isValid && start <= end; + } catch (error) { + return false; + } + } +} diff --git a/app/views/shared/_date_range_picker.html.erb b/app/views/shared/_date_range_picker.html.erb index 0ff5302330..cd20b551e5 100644 --- a/app/views/shared/_date_range_picker.html.erb +++ b/app/views/shared/_date_range_picker.html.erb @@ -5,6 +5,9 @@ placeholder: "January 1, 2011 - December 31, 2011", class: "#{css_class}", autocomplete: "on", data: { + controller: "date-range", + date_range_target: "input", + action: "blur->date-range#validate", initial_start_date: @selected_date_interval.first.strftime("%B %d, %Y"), initial_end_date: @selected_date_interval.last.strftime("%B %d, %Y"), } %> diff --git a/spec/helpers/date_range_helper_spec.rb b/spec/helpers/date_range_helper_spec.rb new file mode 100644 index 0000000000..73b91007bf --- /dev/null +++ b/spec/helpers/date_range_helper_spec.rb @@ -0,0 +1,48 @@ +require "rails_helper" + +RSpec.describe DateRangeHelper do + let(:dummy_class) do + Class.new do + include DateRangeHelper + attr_accessor :params, :flash + + def initialize(params = {}, flash = nil) + @params = params + @flash = flash + end + end + end + + describe "#selected_interval" do + context "with a valid date range" do + it "parses the dates correctly" do + valid_range = "February 21, 2025 - May 22, 2025" + flash_double = double("flash", now: {}) + helper = dummy_class.new({filters: {date_range: valid_range}}, flash_double) + + interval = helper.selected_interval + + expect(interval).to eq([ + Date.new(2025, 2, 21), + Date.new(2025, 5, 22) + ]) + expect(helper.flash.now[:notice]).to be_nil + end + end + + context "with an invalid date range" do + it "falls back to default date range and sets a flash notice" do + invalid_range = "November 08 - February 08" + flash_now = {} + flash_double = double("flash", now: flash_now) + helper = dummy_class.new({filters: {date_range: invalid_range}}, flash_double) + + interval = helper.selected_interval + default_start, default_end = helper.default_date.split(" - ").map { |d| Date.strptime(d, "%B %d, %Y") } + + expect(interval).to eq([default_start, default_end]) + expect(flash_now[:notice]).to eq("Invalid Date range provided. Reset to default date range") + end + end + end +end diff --git a/spec/support/date_range_picker_shared_example.rb b/spec/support/date_range_picker_shared_example.rb index 3c7ba1213d..e2b477e407 100644 --- a/spec/support/date_range_picker_shared_example.rb +++ b/spec/support/date_range_picker_shared_example.rb @@ -83,4 +83,66 @@ def date_range_picker_select_range(range_name) expect(page).to have_css("table tbody tr", count: 1) end end + + context "when entering an invalid date range" do + before do + sign_out user + travel_to Time.zone.local(2019, 7, 31) + sign_in user + end + + # This test is designed to simulate the case where a user tabs into the date range input field, types in an invalid value, + # and then presses Enter to submit the form. In the real application: + # - When the user tabs into the field, the Litepicker.js events (which manage the date range input) don't get triggered. + # - As a result, invalid data can be sent to the server without the client-side validation taking place. + # + # In contrast, if the user clicks on the input field, Litepicker.js would register, validate the input, and reset the + # value to a default range, preventing invalid data from being submitted. + # + # The goal of this test is to ensure that server-side validation works when invalid data is submitted, as it would happen + # when the user tabs into the input, enters invalid data, and submits the form. + # + # However, Capybara's standard methods like `fill_in` or `native.send_keys` trigger the Litepicker.js events, which + # prevent us from testing this edge case. These methods would cause Litepicker.js to validate the input, reset the + # value, and prevent invalid data from being submitted to the server. + # + # To properly test this case, we use `execute_script` to simulate typing the invalid date directly into the input + # field, and submitting the form, bypassing the Litepicker.js events entirely. + it "shows a flash notice and filters results as default" do + visit subject + + date_range = "nov 08 - feb 08" + page.execute_script(<<~JS) + var input = document.getElementById('filters_date_range'); + input.dataset.skipValidation = 'true'; + input.focus(); + input.value = '#{date_range}'; + var form = input.closest('form'); + form.requestSubmit(); + JS + + expect(page).to have_css(".alert.notice", text: "Invalid Date range provided. Reset to default date range") + expect(page).to have_css("table tbody tr", count: 4) + end + + # This test is similar to the above but simulates user clicking away from the date range field + # after having tabbed into it to type something invalid. In this case client side validation + # via a JavaScript alert should be triggered. + it "shows a JavaScript alert when user blurs" do + visit subject + + date_range = "nov 08 - feb 08" + page.execute_script("document.getElementById('filters_date_range').focus();") + page.execute_script("document.getElementById('filters_date_range').value = '#{date_range}';") + + accept_alert("Please enter a valid date range (e.g., January 1, 2024 - March 15, 2024).") do + find('body').click + end + + valid_date_range = "#{Time.zone.local(2019, 7, 22).to_fs(:date_picker)} - #{Time.zone.local(2019, 7, 28).to_fs(:date_picker)}" + fill_in "filters_date_range", with: valid_date_range + find(:id, 'filters_date_range').native.send_keys(:enter) + expect(page).to have_css("table tbody tr", count: 1) + end + end end