Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix #4076: Trigger onCalendarClose event and onChange even when the same date is selected as the start and the end date in a date range #4394

37 changes: 37 additions & 0 deletions src/date_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -862,3 +862,40 @@
export function isSameMinute(d1, d2) {
return startOfMinute(d1).getTime() === startOfMinute(d2).getTime();
}

/**
* Returns a cloned date with midnight time (00:00:00)
*
* @param {Date} date The date for which midnight time is required
* @param {Date} dateToCompare the date to compare with
* @returns {Date} A new datetime object representing the input date with midnight time
*/
export function getMidnightDate(date) {
if (!isDate(date)) {
throw new Error("Invalid date");
}

const dateWithoutTime = new Date(date);
dateWithoutTime.setHours(0, 0, 0, 0);
return dateWithoutTime;
}

/**
* Is the first date before the second one?
*
* @param {Date} date The date that should be before the other one to return true
* @param {Date} dateToCompare The date to compare with
* @returns {boolean} The first date is before the second date
*
* Note:
* This function considers the mid-night of the given dates for comparison.
* It evaluates whether date is before dateToCompare based on their mid-night timestamps.
*/
export function isDateBefore(date, dateToCompare) {
const midnightDate = isDate(date) ? getMidnightDate(date) : null;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you not want to throw an exception if one tries to compare an invalid date?

🔹 Error Handling (Nice to have)

Image of Jacques Jacques

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, we are returning false, which is technically correct.  Throwing an exception would make it even more a better implementation I guess.  I'll update the change.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the cod to throw an error when it receives an invalid date

const midnightDateToCompare = isDate(dateToCompare)
? getMidnightDate(dateToCompare)
: null;

Check warning on line 898 in src/date_utils.js

View check run for this annotation

Codecov / codecov/patch

src/date_utils.js#L898

Added line #L898 was not covered by tests

return isBefore(midnightDate, midnightDateToCompare);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so i'm guessing this returns either -1, 0 or 1 like the datefns functions compareAsc etc? I am not familiar with your implementation of isBefore

it seems from your PR description that the issue is just that sometimes a difference of a few milliseconds causes the events to fire incorrectly. It seems like rounding to the nearest day is a rather blunt instrument for such a small difference.

🔸 Bug (Important)

Image of Andy W Andy W

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Andy,

Thank you for your comment.  I'll quickly describe the issue, the calender is not automatically getting closed when the startDate and the endDate of the date range are same, the dates are getting selected, but users need to click outside to close the calendar.  This issue will only come when there is a default value for the startDate with time, means we have some time attached to the startDate, but the dates emitted from the calendar component don't have time.  That was the issue.

Suppose if there is no default date set for the startDate or there is a default date with no time, in that case the existing code will work, because we don't need to append any time at the later part of the time, so it'll work.

But the issue comes when we have a default startDate with time (in the specified example in the issue, as it's Date.now() will give current date with time), because to decide whether we need to close the calendar or not, we'll be checking whether the selected endDate occurs before the startDate.  Here as the startDate has the time which is passed to it from the props, the startDate looks greater than the endDate, and the close operation will be skipped.  But for the calendar close operation, why we need to care about the time, because there the language is only in date and not in time.  Hence I added a new helper called isDateBefore.  We already have a helper from the date-fns called isBefore, isBefore will check whether one date is before another date, it also includes time in its comparision.  But in our case, we just need to take the date part of it, to check whether a selected date is before or after the currently selected endDate.  So, I create this new helper called isDateBefore, which will round the time part of the dates to the mindNight time and compare, so here the date will be retained, but just the time will be rounded which will help us for our comparision.

}
35 changes: 19 additions & 16 deletions src/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
isMonthDisabled,
isYearDisabled,
getHolidaysMap,
isDateBefore,
} from "./date_utils";
import TabLoop from "./tab_loop";
import onClickOutside from "react-onclickoutside";
Expand Down Expand Up @@ -360,10 +361,10 @@ export default class DatePicker extends React.Component {
this.props.openToDate
? this.props.openToDate
: this.props.selectsEnd && this.props.startDate
? this.props.startDate
: this.props.selectsStart && this.props.endDate
? this.props.endDate
: newDate();
? this.props.startDate
: this.props.selectsStart && this.props.endDate
? this.props.endDate
: newDate();

// Convert the date from string format to standard Date format
modifyHolidays = () =>
Expand All @@ -384,8 +385,8 @@ export default class DatePicker extends React.Component {
minDate && isBefore(defaultPreSelection, startOfDay(minDate))
? minDate
: maxDate && isAfter(defaultPreSelection, endOfDay(maxDate))
? maxDate
: defaultPreSelection;
? maxDate
: defaultPreSelection;
return {
open: this.props.startOpen || false,
preventFocus: false,
Expand Down Expand Up @@ -589,8 +590,10 @@ export default class DatePicker extends React.Component {
if (!this.props.selectsRange) {
this.setOpen(false);
}

const { startDate, endDate } = this.props;
if (startDate && !endDate && !isBefore(date, startDate)) {

if (startDate && !endDate && !isDateBefore(date, startDate)) {
this.setOpen(false);
}
}
Expand Down Expand Up @@ -653,7 +656,7 @@ export default class DatePicker extends React.Component {
if (noRanges) {
onChange([changedDate, null], event);
} else if (hasStartRange) {
if (isBefore(changedDate, startDate)) {
if (isDateBefore(changedDate, startDate)) {
onChange([changedDate, null], event);
} else {
onChange([startDate, changedDate], event);
Expand Down Expand Up @@ -1175,14 +1178,14 @@ export default class DatePicker extends React.Component {
typeof this.props.value === "string"
? this.props.value
: typeof this.state.inputValue === "string"
? this.state.inputValue
: this.props.selectsRange
? safeDateRangeFormat(
this.props.startDate,
this.props.endDate,
this.props,
)
: safeDateFormat(this.props.selected, this.props);
? this.state.inputValue
: this.props.selectsRange
? safeDateRangeFormat(
this.props.startDate,
this.props.endDate,
this.props,
)
: safeDateFormat(this.props.selected, this.props);

return React.cloneElement(customInput, {
[customInputRef]: (input) => {
Expand Down
49 changes: 49 additions & 0 deletions test/date_utils_test.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
getHolidaysMap,
arraysAreEqual,
startOfMinute,
isDateBefore,
getMidnightDate,
} from "../src/date_utils";
import setMinutes from "date-fns/setMinutes";
import setHours from "date-fns/setHours";
Expand Down Expand Up @@ -1248,4 +1250,51 @@ describe("date_utils", () => {
expect(startOfMinute(d)).toEqual(expected);
});
});

describe("getMidnightDate", () => {
it("should return a date with midnight time when a valid date is provided", () => {
const inputDate = new Date(2023, 0, 1, 12, 30, 45); // January 1, 2023, 12:30:45 PM

const result = getMidnightDate(inputDate);

expect(result).toEqual(new Date(2023, 0, 1, 0, 0, 0, 0)); // January 1, 2023, 00:00:00.000
});

it("should throw an error when an invalid date is provided", () => {
const invalidDate = "not a date";

expect(() => {
getMidnightDate(invalidDate);
}).toThrowError("Invalid date");
});
});

describe("isDateBefore", () => {
it("should return true when date is before dateToCompare", () => {
const date = new Date(2022, 11, 31); // December 31, 2022
const dateToCompare = new Date(2023, 0, 1); // January 1, 2023

const result = isDateBefore(date, dateToCompare);

expect(result).toBe(true);
});

it("should return false when date is not before dateToCompare", () => {
const date = new Date(2023, 0, 1); // January 1, 2023
const dateToCompare = new Date(2022, 11, 31); // December 31, 2022

const result = isDateBefore(date, dateToCompare);

expect(result).toBe(false);
});

it("should return false when either date or dateToCompare is not a valid date", () => {
const invalidDate = "not a date";
const validDate = new Date(2023, 0, 1); // January 1, 2023

const result = isDateBefore(invalidDate, validDate);

expect(result).toBe(false);
});
});
});
102 changes: 101 additions & 1 deletion test/datepicker_test.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { findDOMNode } from "react-dom";
import TestUtils from "react-dom/test-utils";
import { enUS, enGB } from "date-fns/locale";
import { mount } from "enzyme";
import { render, fireEvent } from "@testing-library/react";
import { render, act, waitFor, fireEvent } from "@testing-library/react";
import defer from "lodash/defer";
import DatePicker, { registerLocale } from "../src/index.jsx";
import Day from "../src/day.jsx";
Expand Down Expand Up @@ -44,6 +44,18 @@ function goToLastMonth(datePicker) {
TestUtils.Simulate.click(findDOMNode(lastMonthButton));
}

function formatDayWithZeros(day) {
const dayString = day.toString();

if (dayString.length === 1) {
return `00${dayString}`;
}
if (dayString.length === 2) {
return `0${dayString}`;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not able to wrap my head around why a day would be 3 digits or why this padding makes sense... is this correct?

🔺 Bug (Critical)

Image of Steven S Steven S

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Steven,
Actually we're using 3-digit numbers for our CSS class names. Attaching the below screenshot for your reference.

Calendar Screenshot

}
return dayString;
}

describe("DatePicker", () => {
afterEach(() => {
jest.resetAllMocks();
Expand Down Expand Up @@ -1941,6 +1953,94 @@ describe("DatePicker", () => {
expect(onChangeSpy.mock.calls[0][0][0]).toBeNull();
expect(onChangeSpy.mock.calls[0][0][1]).toBeNull();
});

it("should call the onChange even when the startDate and the endDate is same in the range (case when we programmatically set the startDate, but set the same endDate through UI)", async () => {
let startDate = new Date();
let endDate = null;

const onChangeSpy = jest.fn();

const { container } = render(
<DatePicker
startDate={startDate}
endDate={endDate}
onChange={onChangeSpy}
shouldCloseOnSelect
selectsRange
/>,
);

const input = container.querySelector("input");
expect(input).toBeTruthy();
fireEvent.click(input);

let calendar = container.querySelector(".react-datepicker");
expect(calendar).toBeTruthy();

// Select the same date as the start date
const startDatePrefixedWithZeros = formatDayWithZeros(
startDate.getDate(),
);
const endDateElement = container.querySelector(
`.react-datepicker__day--${startDatePrefixedWithZeros}`,
);
fireEvent.click(endDateElement);

await act(async () => {
await waitFor(() => {
expect(onChangeSpy).toHaveBeenCalled();
});
});
});

it("should hide the calendar even when the startDate and the endDate is same in the range", async () => {
let startDate = new Date();
let endDate = null;

const onCalendarCloseSpy = jest.fn();

const onChange = (dates) => {
const [start, end] = dates;
startDate = start;
endDate = end;
};

const { container } = render(
<DatePicker
startDate={startDate}
endDate={endDate}
onChange={onChange}
onCalendarClose={onCalendarCloseSpy}
shouldCloseOnSelect
selectsRange
/>,
);

const input = container.querySelector("input");
expect(input).toBeTruthy();
fireEvent.click(input);

let calendar = container.querySelector(".react-datepicker");
expect(calendar).toBeTruthy();

// Select the same date as the start date
const startDatePrefixedWithZeros = formatDayWithZeros(
startDate.getDate(),
);
const endDateElement = container.querySelector(
`.react-datepicker__day--${startDatePrefixedWithZeros}`,
);
fireEvent.click(endDateElement);

await act(async () => {
await waitFor(() => {
calendar = container.querySelector(".react-datepicker");
expect(calendar).toBeFalsy();

expect(onCalendarCloseSpy).toHaveBeenCalled();
});
});
});
});

describe("duplicate dates when multiple months", () => {
Expand Down