Skip to content

Commit f0f2f61

Browse files
authored
[#4922] handle invalid date ranges in date range input (#5192)
* [#4922] handle invalid date ranges in date range input * Fixed issue where tabbing into date range field and typing invalid data bypassed Litepicker validation * Updated DateRangeHelper to catch parsing errors and show flash notice instead of raising 500 * Added custom client-side check in Stimulus controller to catch bad input on blur * Introduced global isLitePickerActive flag in application.js so that custom date range controller can avoid alerting user if Litepicker is open * temporarily remove invalid date range system tests to investigate CI failures * Revert "temporarily remove invalid date range system tests to investigate CI failures" This reverts commit f8ccea2. * temp changes to investigate js timing failure for system tests on ci * remove attempt to focus tests for ci failure investigation * temp ci failure investigation - run only distribution system tests which include invalid date range testing * temp investigation for ci failure - try to get to failure quickly * investigate ci failure - attempt to submit form without js having a chance to simulate invalid data getting to server * temp ci investigation - focus only on server side validation test * more ci failure investigation - how far does it get before js alert popups up * experiment to temp disable custom js validation in test to simulate server side validation * now that server side validation test passes on ci, try also with client side validation test * try with all tests * cleanup CI test failure investigation code * remove unused data test id from filter button * document disable validation option for client side JS * consolidate all js execution in test * undo changes to rspec system workflow file
1 parent 495d1c0 commit f0f2f61

File tree

6 files changed

+264
-54
lines changed

6 files changed

+264
-54
lines changed

app/helpers/date_range_helper.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ def selected_interval
3737
date_range_params.split(" - ").map do |d|
3838
Date.strptime(d, "%B %d, %Y")
3939
rescue
40-
raise "Invalid date: #{d} in #{date_range_params}"
40+
flash.now[:notice] = "Invalid Date range provided. Reset to default date range"
41+
return default_date.split(" - ").map do |d|
42+
Date.strptime(d.to_s, "%B %d, %Y")
43+
end
4144
end
4245
end
4346

app/javascript/application.js

Lines changed: 94 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ toastr.options = {
4949
"timeOut": "1400"
5050
}
5151

52+
// This global variable tracks whether Litepicker is actively managing the date range input field.
53+
// It prevents custom validation logic from interfering when Litepicker is in use.
54+
window.isLitepickerActive = false;
55+
5256
function isMobileResolution() {
5357
return $(window).width() < 992;
5458
}
@@ -58,57 +62,94 @@ function isShortHeightScreen() {
5862
}
5963

6064
$(document).ready(function(){
61-
const hash = window.location.hash;
62-
if (hash) {
63-
$('ul.nav a[href="' + hash + '"]').tab('show');
64-
}
65-
const isMobile = isMobileResolution();
66-
const isShortHeight = isShortHeightScreen();
67-
68-
const calendarElement = document.getElementById('calendar');
69-
if (calendarElement) {
70-
new Calendar(calendarElement, {
71-
timeZone: 'UTC',
72-
firstDay: 1,
73-
plugins: [luxonPlugin, dayGridPlugin, listPlugin],
74-
displayEventTime: true,
75-
eventLimit: true,
76-
events: 'schedule.json',
77-
height: isMobile || isShortHeight ? 'auto' : 'parent',
78-
defaultView: isMobile ? 'listWeek' : 'month'
79-
}).render();
80-
}
81-
82-
const rangeElement = document.getElementById("filters_date_range");
83-
if (!rangeElement) {
84-
return;
85-
}
86-
const today = DateTime.now();
87-
const startDate = new Date(rangeElement.dataset["initialStartDate"]);
88-
const endDate = new Date(rangeElement.dataset["initialEndDate"]);
89-
90-
const picker = new Litepicker({
91-
element: rangeElement,
92-
plugins: ['ranges'],
93-
startDate: startDate,
94-
endDate: endDate,
95-
format: "MMMM D, YYYY",
96-
ranges: {
97-
customRanges: {
98-
'Default': [today.minus({'months': 2}).toJSDate(), today.plus({'months': 1}).toJSDate()],
99-
'All Time': [today.minus({ 'years': 100 }).toJSDate(), today.plus({ 'years': 1 }).toJSDate()],
100-
'Today': [today.toJSDate(), today.toJSDate()],
101-
'Yesterday': [today.minus({'days': 1}).toJSDate(), today.minus({'days': 1}).toJSDate()],
102-
'Last 7 Days': [today.minus({'days': 6}).toJSDate(), today.toJSDate()],
103-
'Last 30 Days': [today.minus({'days': 29}).toJSDate(), today.toJSDate()],
104-
'This Month': [today.startOf('month').toJSDate(), today.endOf('month').toJSDate()],
105-
'Last Month': [today.minus({'months': 1}).startOf('month').toJSDate(),
106-
today.minus({'month': 1}).endOf('month').toJSDate()],
107-
'Last 12 Months': [today.minus({'months': 12}).plus({'days': 1}).toJSDate(), today.toJSDate()],
108-
'Prior Year': [today.startOf('year').minus({'years': 1}).toJSDate(), today.minus({'year': 1}).endOf('year').toJSDate()],
109-
'This Year': [today.startOf('year').toJSDate(), today.endOf('year').toJSDate()],
110-
}
111-
}
112-
});
113-
picker.setDateRange(startDate, endDate);
65+
const hash = window.location.hash;
66+
if (hash) {
67+
$('ul.nav a[href="' + hash + '"]').tab("show");
68+
}
69+
const isMobile = isMobileResolution();
70+
const isShortHeight = isShortHeightScreen();
71+
72+
const calendarElement = document.getElementById("calendar");
73+
if (calendarElement) {
74+
new Calendar(calendarElement, {
75+
timeZone: "UTC",
76+
firstDay: 1,
77+
plugins: [luxonPlugin, dayGridPlugin, listPlugin],
78+
displayEventTime: true,
79+
eventLimit: true,
80+
events: "schedule.json",
81+
height: isMobile || isShortHeight ? "auto" : "parent",
82+
defaultView: isMobile ? "listWeek" : "month",
83+
}).render();
84+
}
85+
86+
const rangeElement = document.getElementById("filters_date_range");
87+
if (!rangeElement) {
88+
return;
89+
}
90+
const today = DateTime.now();
91+
const startDate = new Date(rangeElement.dataset["initialStartDate"]);
92+
const endDate = new Date(rangeElement.dataset["initialEndDate"]);
93+
94+
const picker = new Litepicker({
95+
element: rangeElement,
96+
plugins: ["ranges"],
97+
startDate: startDate,
98+
endDate: endDate,
99+
format: "MMMM D, YYYY",
100+
ranges: {
101+
customRanges: {
102+
Default: [
103+
today.minus({ months: 2 }).toJSDate(),
104+
today.plus({ months: 1 }).toJSDate(),
105+
],
106+
"All Time": [
107+
today.minus({ years: 100 }).toJSDate(),
108+
today.plus({ years: 1 }).toJSDate(),
109+
],
110+
Today: [today.toJSDate(), today.toJSDate()],
111+
Yesterday: [
112+
today.minus({ days: 1 }).toJSDate(),
113+
today.minus({ days: 1 }).toJSDate(),
114+
],
115+
"Last 7 Days": [today.minus({ days: 6 }).toJSDate(), today.toJSDate()],
116+
"Last 30 Days": [
117+
today.minus({ days: 29 }).toJSDate(),
118+
today.toJSDate(),
119+
],
120+
"This Month": [
121+
today.startOf("month").toJSDate(),
122+
today.endOf("month").toJSDate(),
123+
],
124+
"Last Month": [
125+
today.minus({ months: 1 }).startOf("month").toJSDate(),
126+
today.minus({ month: 1 }).endOf("month").toJSDate(),
127+
],
128+
"Last 12 Months": [
129+
today.minus({ months: 12 }).plus({ days: 1 }).toJSDate(),
130+
today.toJSDate(),
131+
],
132+
"Prior Year": [
133+
today.startOf("year").minus({ years: 1 }).toJSDate(),
134+
today.minus({ year: 1 }).endOf("year").toJSDate(),
135+
],
136+
"This Year": [
137+
today.startOf("year").toJSDate(),
138+
today.endOf("year").toJSDate(),
139+
],
140+
},
141+
},
142+
});
143+
144+
// litepicker docs aren't clear on how to register events
145+
// https://github.com/wakirin/Litepicker/issues/301
146+
picker.on("show", () => {
147+
window.isLitepickerActive = true;
148+
});
149+
150+
picker.on("hide", () => {
151+
window.isLitepickerActive = false;
152+
});
153+
154+
picker.setDateRange(startDate, endDate);
114155
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// This Stimulus controller is used to handle custom validation for the date range input field.
2+
// Litepicker.js manages the date range field and prevents invalid data when users interact with its calendar control.
3+
// However, if a user tabs into the field and enters invalid data without triggering Litepicker events,
4+
// Litepicker won't validate the input, leaving invalid data in the field.
5+
// This controller ensures that in such cases, custom validation is performed to alert the user about invalid input.
6+
//
7+
// Note: The `data-skip-validation` attribute is used only in automated system tests to disable client-side validation.
8+
// In real user interactions, if a user enters an invalid date and immediately hits Enter, the form submits before
9+
// JS blur-based validation runs, so server-side validation is exercised as expected.
10+
// However, in system tests (especially on CI), the JS blur validation always runs before form submission,
11+
// making it impossible to test server-side validation for this scenario unless client-side validation is disabled.
12+
// This attribute should only be set in test code.
13+
14+
import { Controller } from "@hotwired/stimulus";
15+
import { DateTime } from "luxon";
16+
17+
export default class extends Controller {
18+
static targets = ["input"];
19+
20+
connect() {
21+
this.initialStart = this.inputTarget.dataset.initialStartDate;
22+
this.initialEnd = this.inputTarget.dataset.initialEndDate;
23+
this.format = "MMMM d, yyyy";
24+
}
25+
26+
validate(event) {
27+
event.preventDefault();
28+
29+
if (this.inputTarget.dataset.skipValidation === "true" || window.isLitepickerActive) {
30+
return;
31+
}
32+
33+
const value = this.inputTarget.value.trim();
34+
const [startStr, endStr] = value.split(" - ").map((s) => s.trim());
35+
36+
const isValid = this.isValidDateRange(startStr, endStr);
37+
38+
if (!isValid) {
39+
alert("Please enter a valid date range (e.g., January 1, 2024 - March 15, 2024).")
40+
}
41+
}
42+
43+
isValidDateRange(startStr, endStr) {
44+
try {
45+
const start = DateTime.fromFormat(startStr, this.format);
46+
const end = DateTime.fromFormat(endStr, this.format);
47+
48+
return start.isValid && end.isValid && start <= end;
49+
} catch (error) {
50+
return false;
51+
}
52+
}
53+
}

app/views/shared/_date_range_picker.html.erb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ placeholder: "January 1, 2011 - December 31, 2011",
55
class: "#{css_class}",
66
autocomplete: "on",
77
data: {
8+
controller: "date-range",
9+
date_range_target: "input",
10+
action: "blur->date-range#validate",
811
initial_start_date: @selected_date_interval.first.strftime("%B %d, %Y"),
912
initial_end_date: @selected_date_interval.last.strftime("%B %d, %Y"),
1013
} %>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
require "rails_helper"
2+
3+
RSpec.describe DateRangeHelper do
4+
let(:dummy_class) do
5+
Class.new do
6+
include DateRangeHelper
7+
attr_accessor :params, :flash
8+
9+
def initialize(params = {}, flash = nil)
10+
@params = params
11+
@flash = flash
12+
end
13+
end
14+
end
15+
16+
describe "#selected_interval" do
17+
context "with a valid date range" do
18+
it "parses the dates correctly" do
19+
valid_range = "February 21, 2025 - May 22, 2025"
20+
flash_double = double("flash", now: {})
21+
helper = dummy_class.new({filters: {date_range: valid_range}}, flash_double)
22+
23+
interval = helper.selected_interval
24+
25+
expect(interval).to eq([
26+
Date.new(2025, 2, 21),
27+
Date.new(2025, 5, 22)
28+
])
29+
expect(helper.flash.now[:notice]).to be_nil
30+
end
31+
end
32+
33+
context "with an invalid date range" do
34+
it "falls back to default date range and sets a flash notice" do
35+
invalid_range = "November 08 - February 08"
36+
flash_now = {}
37+
flash_double = double("flash", now: flash_now)
38+
helper = dummy_class.new({filters: {date_range: invalid_range}}, flash_double)
39+
40+
interval = helper.selected_interval
41+
default_start, default_end = helper.default_date.split(" - ").map { |d| Date.strptime(d, "%B %d, %Y") }
42+
43+
expect(interval).to eq([default_start, default_end])
44+
expect(flash_now[:notice]).to eq("Invalid Date range provided. Reset to default date range")
45+
end
46+
end
47+
end
48+
end

spec/support/date_range_picker_shared_example.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,66 @@ def date_range_picker_select_range(range_name)
8383
expect(page).to have_css("table tbody tr", count: 1)
8484
end
8585
end
86+
87+
context "when entering an invalid date range" do
88+
before do
89+
sign_out user
90+
travel_to Time.zone.local(2019, 7, 31)
91+
sign_in user
92+
end
93+
94+
# This test is designed to simulate the case where a user tabs into the date range input field, types in an invalid value,
95+
# and then presses Enter to submit the form. In the real application:
96+
# - When the user tabs into the field, the Litepicker.js events (which manage the date range input) don't get triggered.
97+
# - As a result, invalid data can be sent to the server without the client-side validation taking place.
98+
#
99+
# In contrast, if the user clicks on the input field, Litepicker.js would register, validate the input, and reset the
100+
# value to a default range, preventing invalid data from being submitted.
101+
#
102+
# The goal of this test is to ensure that server-side validation works when invalid data is submitted, as it would happen
103+
# when the user tabs into the input, enters invalid data, and submits the form.
104+
#
105+
# However, Capybara's standard methods like `fill_in` or `native.send_keys` trigger the Litepicker.js events, which
106+
# prevent us from testing this edge case. These methods would cause Litepicker.js to validate the input, reset the
107+
# value, and prevent invalid data from being submitted to the server.
108+
#
109+
# To properly test this case, we use `execute_script` to simulate typing the invalid date directly into the input
110+
# field, and submitting the form, bypassing the Litepicker.js events entirely.
111+
it "shows a flash notice and filters results as default" do
112+
visit subject
113+
114+
date_range = "nov 08 - feb 08"
115+
page.execute_script(<<~JS)
116+
var input = document.getElementById('filters_date_range');
117+
input.dataset.skipValidation = 'true';
118+
input.focus();
119+
input.value = '#{date_range}';
120+
var form = input.closest('form');
121+
form.requestSubmit();
122+
JS
123+
124+
expect(page).to have_css(".alert.notice", text: "Invalid Date range provided. Reset to default date range")
125+
expect(page).to have_css("table tbody tr", count: 4)
126+
end
127+
128+
# This test is similar to the above but simulates user clicking away from the date range field
129+
# after having tabbed into it to type something invalid. In this case client side validation
130+
# via a JavaScript alert should be triggered.
131+
it "shows a JavaScript alert when user blurs" do
132+
visit subject
133+
134+
date_range = "nov 08 - feb 08"
135+
page.execute_script("document.getElementById('filters_date_range').focus();")
136+
page.execute_script("document.getElementById('filters_date_range').value = '#{date_range}';")
137+
138+
accept_alert("Please enter a valid date range (e.g., January 1, 2024 - March 15, 2024).") do
139+
find('body').click
140+
end
141+
142+
valid_date_range = "#{Time.zone.local(2019, 7, 22).to_fs(:date_picker)} - #{Time.zone.local(2019, 7, 28).to_fs(:date_picker)}"
143+
fill_in "filters_date_range", with: valid_date_range
144+
find(:id, 'filters_date_range').native.send_keys(:enter)
145+
expect(page).to have_css("table tbody tr", count: 1)
146+
end
147+
end
86148
end

0 commit comments

Comments
 (0)