Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
89e2176
[#4922] handle invalid date ranges in date range input
danielabar Apr 21, 2025
f8ccea2
temporarily remove invalid date range system tests to investigate CI …
danielabar May 17, 2025
ad51bd7
Revert "temporarily remove invalid date range system tests to investi…
danielabar May 17, 2025
aaf82ef
temp changes to investigate js timing failure for system tests on ci
danielabar May 18, 2025
f019420
remove attempt to focus tests for ci failure investigation
danielabar May 18, 2025
3e5a8b0
temp ci failure investigation - run only distribution system tests wh…
danielabar May 18, 2025
23beb1a
temp investigation for ci failure - try to get to failure quickly
danielabar May 18, 2025
540c6cd
investigate ci failure - attempt to submit form without js having a c…
danielabar May 18, 2025
f7faba9
temp ci investigation - focus only on server side validation test
danielabar May 18, 2025
d578091
more ci failure investigation - how far does it get before js alert p…
danielabar May 18, 2025
2b237f1
experiment to temp disable custom js validation in test to simulate s…
danielabar May 18, 2025
47ab119
now that server side validation test passes on ci, try also with clie…
danielabar May 18, 2025
cea3a0d
try with all tests
danielabar May 18, 2025
4f25aff
cleanup CI test failure investigation code
danielabar May 18, 2025
aebd446
remove unused data test id from filter button
danielabar May 18, 2025
4847e74
document disable validation option for client side JS
danielabar May 18, 2025
c4f1273
consolidate all js execution in test
danielabar May 18, 2025
ac155a6
undo changes to rspec system workflow file
danielabar May 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/helpers/date_range_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
147 changes: 94 additions & 53 deletions app/javascript/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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);
});
53 changes: 53 additions & 0 deletions app/javascript/controllers/date_range_controller.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
3 changes: 3 additions & 0 deletions app/views/shared/_date_range_picker.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
} %>
Expand Down
48 changes: 48 additions & 0 deletions spec/helpers/date_range_helper_spec.rb
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions spec/support/date_range_picker_shared_example.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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