From 883353ce80022fc5614a3f83fcec1f94d9705a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1r=20=C3=96rlygsson?= Date: Thu, 6 Apr 2023 13:40:25 +0000 Subject: [PATCH 1/9] tests: Add failing locale test: bad value is parsed using default locale Parsing `valueEn` as a Portugese date should fail, but accidentally gets parsed using the default/en-US locale, because parseDate internally retries and accidentally omits[^1] the original locale option that time around. [^1]: https://github.com/Hacker0x01/react-datepicker/blob/5c1d6d923931535f105f3dddbb6f3e10fd8dd25c/src/date_utils.js#L121 --- src/test/date_utils_test.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/test/date_utils_test.test.ts b/src/test/date_utils_test.test.ts index a3d5c2511..5893e2079 100644 --- a/src/test/date_utils_test.test.ts +++ b/src/test/date_utils_test.test.ts @@ -1014,6 +1014,22 @@ describe("date_utils", () => { expect(actual).toEqual(expected); }); + it("should parse date based on locale w/o strict", () => { + const valuePt = "26. fev 1995"; + const valueEn = "26. feb 1995"; + + const locale = "pt-BR"; + const dateFormat = "d. MMM yyyy"; + + const expected = new Date(1995, 1, 26); + + expect(parseDate(valuePt, dateFormat, locale, false)).toEqual(expected); + expect(parseDate(valueEn, dateFormat, undefined, false)).toEqual( + expected, + ); + expect(parseDate(valueEn, dateFormat, locale, false)).toBeNull(); + }); + it("should not parse date based on locale without a given locale", () => { const value = "26/05/1995"; const dateFormat = "P"; From 16b9c9479a4bcda4275d539ca5457cb1607407a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1r=20=C3=96rlygsson?= Date: Thu, 19 Jan 2023 15:54:23 +0100 Subject: [PATCH 2/9] fix: Remove weird, redundant code, fix bug illustrated in #45ce1fa3 --- src/date_utils.ts | 31 ++----------------------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/src/date_utils.ts b/src/date_utils.ts index 7323219b6..1869b156d 100644 --- a/src/date_utils.ts +++ b/src/date_utils.ts @@ -14,7 +14,7 @@ import { endOfDay } from "date-fns/endOfDay"; import { endOfMonth } from "date-fns/endOfMonth"; import { endOfWeek } from "date-fns/endOfWeek"; import { endOfYear } from "date-fns/endOfYear"; -import { format, longFormatters } from "date-fns/format"; +import { format } from "date-fns/format"; import { getDate } from "date-fns/getDate"; import { getDay } from "date-fns/getDay"; import { getHours } from "date-fns/getHours"; @@ -100,10 +100,6 @@ function getLocaleScope() { export const DEFAULT_YEAR_ITEM_NUMBER = 12; -// This RegExp catches symbols escaped by quotes, and also -// sequences of symbols P, p, and the combinations like `PPPPPPPppppp` -const longFormattingTokensRegExp = /P+p+|P+|p+|''|'(''|[^'])+('|$)|./g; - // ** Date Constructors ** export function newDate(value?: string | Date | number | null): Date { @@ -166,30 +162,7 @@ export function parseDate( isValid(parsedDate) && value === formatDate(parsedDate, dateFormat, locale); } else if (!isValid(parsedDate)) { - const format = (dateFormat.match(longFormattingTokensRegExp) ?? []) - .map(function (substring) { - const firstCharacter = substring[0]; - if (firstCharacter === "p" || firstCharacter === "P") { - // The type in date-fns is `Record` so we can do our firstCharacter a bit loos but I don't think that this is a good idea - const longFormatter = longFormatters[firstCharacter]!; - return localeObject - ? longFormatter(substring, localeObject.formatLong) - : firstCharacter; - } - return substring; - }) - .join(""); - - if (value.length > 0) { - parsedDate = parse(value, format.slice(0, value.length), new Date(), { - useAdditionalWeekYearTokens: true, - useAdditionalDayOfYearTokens: true, - }); - } - - if (!isValid(parsedDate)) { - parsedDate = new Date(value); - } + parsedDate = new Date(value); } return isValid(parsedDate) && strictParsingValueMatch ? parsedDate : null; From 6dfbbe6e12091a9e64d70fb86edfcdab0f87e94b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1r=20=C3=96rlygsson?= Date: Mon, 23 Jan 2023 09:09:53 +0000 Subject: [PATCH 3/9] tests: Fix side-effecty+inconsistent formatting of date value inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …making sure input values are applied in a more consistent manner, and with formatting that matches the currently active dateFormat (usually the default format). This only affects clarity/readability. All tests still pass. --- src/test/date_utils_test.test.ts | 2 +- src/test/datepicker_test.test.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/test/date_utils_test.test.ts b/src/test/date_utils_test.test.ts index 5893e2079..b1e8b5669 100644 --- a/src/test/date_utils_test.test.ts +++ b/src/test/date_utils_test.test.ts @@ -998,7 +998,7 @@ describe("date_utils", () => { }); it("should parse date without strict parsing", () => { - const value = "01/15/20"; + const value = "1/2/2020"; const dateFormat = "MM/dd/yyyy"; expect(parseDate(value, dateFormat, undefined, false)).not.toBeNull(); diff --git a/src/test/datepicker_test.test.tsx b/src/test/datepicker_test.test.tsx index d395e82b1..9d43d73f2 100644 --- a/src/test/datepicker_test.test.tsx +++ b/src/test/datepicker_test.test.tsx @@ -912,7 +912,7 @@ describe("DatePicker", () => { const input = container.querySelector("input"); fireEvent.change(input ?? new HTMLInputElement(), { target: { - value: newDate("2014-01-02"), + value: "01/02/2014", }, }); @@ -1679,7 +1679,7 @@ describe("DatePicker", () => { return render( , ); @@ -1691,11 +1691,11 @@ describe("DatePicker", () => { fireEvent.change(input ?? new HTMLElement(), { target: { - value: "1801/01/01", + value: "01/01/1801", }, }); - expect(container.querySelector("input")?.value).toBe("1801/01/01"); + expect(container.querySelector("input")?.value).toBe("01/01/1801"); expect( container.querySelector(".react-datepicker__current-month")?.innerHTML, ).toBe("January 1801"); @@ -1779,7 +1779,7 @@ describe("DatePicker", () => { it("should update the selected date on manual input", () => { const data = getOnInputKeyDownStuff(); fireEvent.change(data.dateInput, { - target: { value: "02/02/2017" }, + target: { value: "2017-02-02" }, }); fireEvent.keyDown(data.dateInput, getKey(KeyType.Enter)); data.copyM = newDate("2017-02-02"); From e4ed03164b7e8961d24f61eac42b4b63e1a147b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1r=20=C3=96rlygsson?= Date: Mon, 23 Jan 2023 09:16:21 +0000 Subject: [PATCH 4/9] fix: `parseDate` should prefer the first matching format in array Fix involves vastly simplifying the internal code-paths of `parseDate`, to prevent further and repeated divergence of behavior when parsing `dateFormat` as `Array` vs. as `string` NOTE: Removing the (redundant) `minDate` parameter has no effect on the tests, as minDate/maxDate boundry checks are enforced elsewhere in the component's value-updating lifecycle. NOTE 2: Adding instead `refDate` (using `props.selected`) to fully utilize the features of `date-fns/parse`. NOTE 3: The old behavior of re-parsing borked values using `new Date()` was somewhat dubious as it gave different results depending on the Browser/OS running the code. See more here: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date#parameters --- src/date_utils.ts | 57 +++++++++----------------------- src/index.tsx | 2 +- src/test/date_utils_test.test.ts | 26 ++++++++++++++- src/test/min_time_test.test.tsx | 2 +- 4 files changed, 43 insertions(+), 44 deletions(-) diff --git a/src/date_utils.ts b/src/date_utils.ts index 1869b156d..982739b4f 100644 --- a/src/date_utils.ts +++ b/src/date_utils.ts @@ -118,7 +118,7 @@ export function newDate(value?: string | Date | number | null): Date { * @param dateFormat - The date format. * @param locale - The locale. * @param strictParsing - The strict parsing flag. - * @param minDate - The minimum date. + * @param refDate - The base date to be passed to date-fns parse() function. * @returns - The parsed date or null. */ export function parseDate( @@ -126,46 +126,27 @@ export function parseDate( dateFormat: string | string[], locale: Locale | undefined, strictParsing: boolean, - minDate?: Date, + refDate?: Date, ): Date | null { - let parsedDate = null; const localeObject = getLocaleObject(locale) || getLocaleObject(getDefaultLocale()); - let strictParsingValueMatch = true; - if (Array.isArray(dateFormat)) { - dateFormat.forEach((df) => { - const tryParseDate = parse(value, df, new Date(), { - locale: localeObject, - useAdditionalWeekYearTokens: true, - useAdditionalDayOfYearTokens: true, - }); - if (strictParsing) { - strictParsingValueMatch = - isValid(tryParseDate, minDate) && - value === formatDate(tryParseDate, df, locale); - } - if (isValid(tryParseDate, minDate) && strictParsingValueMatch) { - parsedDate = tryParseDate; - } - }); - return parsedDate; - } - parsedDate = parse(value, dateFormat, new Date(), { - locale: localeObject, - useAdditionalWeekYearTokens: true, - useAdditionalDayOfYearTokens: true, - }); + const formats = Array.isArray(dateFormat) ? dateFormat : [dateFormat]; - if (strictParsing) { - strictParsingValueMatch = + for (const format of formats) { + const parsedDate = parse(value, format, refDate || newDate(), { + locale: localeObject, + useAdditionalWeekYearTokens: true, + useAdditionalDayOfYearTokens: true, + }); + if ( isValid(parsedDate) && - value === formatDate(parsedDate, dateFormat, locale); - } else if (!isValid(parsedDate)) { - parsedDate = new Date(value); + (!strictParsing || value === formatDate(parsedDate, format, locale)) + ) { + return parsedDate; + } } - - return isValid(parsedDate) && strictParsingValueMatch ? parsedDate : null; + return null; } // ** Date "Reflection" ** @@ -213,13 +194,7 @@ export function formatDate( `A locale object was not found for the provided string ["${locale}"].`, ); } - if ( - !localeObj && - !!getDefaultLocale() && - !!getLocaleObject(getDefaultLocale()) - ) { - localeObj = getLocaleObject(getDefaultLocale()); - } + localeObj = localeObj || getLocaleObject(getDefaultLocale()); return format(date, formatStr, { locale: localeObj, useAdditionalWeekYearTokens: true, diff --git a/src/index.tsx b/src/index.tsx index e19ceef7c..267e291a9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -606,7 +606,7 @@ export default class DatePicker extends Component< dateFormat, this.props.locale, strictParsing, - this.props.minDate, + this.props.selected ?? undefined, ); // Use date from `selected` prop when manipulating only time for input value if ( diff --git a/src/test/date_utils_test.test.ts b/src/test/date_utils_test.test.ts index b1e8b5669..7644a5bf5 100644 --- a/src/test/date_utils_test.test.ts +++ b/src/test/date_utils_test.test.ts @@ -978,11 +978,35 @@ describe("date_utils", () => { it("should parse date that matches one of the formats", () => { const value = "01/15/2019"; - const dateFormat = ["MM/dd/yyyy", "yyyy-MM-dd"]; + const dateFormat = ["yyyy-MM-dd", "MM/dd/yyyy"]; expect(parseDate(value, dateFormat, undefined, true)).not.toBeNull(); }); + it("should prefer the first matching format in array (strict)", () => { + const value = "01/06/2019"; + const valueLax = "1/6/2019"; + const dateFormat = ["MM/dd/yyyy", "dd/MM/yyyy"]; + + const expected = new Date(2019, 0, 6); + + expect(parseDate(value, dateFormat, undefined, true)).toEqual(expected); + expect(parseDate(valueLax, dateFormat, undefined, true)).toBeNull(); + }); + + it("should prefer the first matching format in array", () => { + const value = "01/06/2019"; + const valueLax = "1/6/2019"; + const dateFormat = ["MM/dd/yyyy", "dd/MM/yyyy"]; + + const expected = new Date(2019, 0, 6); + + expect(parseDate(value, dateFormat, undefined, false)).toEqual(expected); + expect(parseDate(valueLax, dateFormat, undefined, false)).toEqual( + expected, + ); + }); + it("should not parse date that does not match the format", () => { const value = "01/15/20"; const dateFormat = "MM/dd/yyyy"; diff --git a/src/test/min_time_test.test.tsx b/src/test/min_time_test.test.tsx index c73051bd3..fe91acacc 100644 --- a/src/test/min_time_test.test.tsx +++ b/src/test/min_time_test.test.tsx @@ -79,7 +79,7 @@ describe("Datepicker minTime", () => { , ); const input = container.querySelector("input") ?? new HTMLInputElement(); - fireEvent.change(input, { target: { value: "2023-03-10 16:00" } }); + fireEvent.change(input, { target: { value: "03/10/2023 16:00" } }); fireEvent.focusOut(input); expect(input.value).toEqual("03/10/2023 16:00"); From e80b0e71596379506ff6a3b001fb8d87fb997bcb Mon Sep 17 00:00:00 2001 From: laug <5089281+laug@users.noreply.github.com> Date: Sat, 17 Aug 2024 13:07:01 +0200 Subject: [PATCH 5/9] fix: initialize param with default argument Initialize refDate with newDate() as a default argument. Suggested by @pullrequest @buu700. --- src/date_utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/date_utils.ts b/src/date_utils.ts index 982739b4f..b77398da0 100644 --- a/src/date_utils.ts +++ b/src/date_utils.ts @@ -126,7 +126,7 @@ export function parseDate( dateFormat: string | string[], locale: Locale | undefined, strictParsing: boolean, - refDate?: Date, + refDate: Date = newDate(), ): Date | null { const localeObject = getLocaleObject(locale) || getLocaleObject(getDefaultLocale()); @@ -134,7 +134,7 @@ export function parseDate( const formats = Array.isArray(dateFormat) ? dateFormat : [dateFormat]; for (const format of formats) { - const parsedDate = parse(value, format, refDate || newDate(), { + const parsedDate = parse(value, format, refDate, { locale: localeObject, useAdditionalWeekYearTokens: true, useAdditionalDayOfYearTokens: true, From e9fe0bfbe2adcf74b5c439d2558a2182499c6b82 Mon Sep 17 00:00:00 2001 From: laug <5089281+laug@users.noreply.github.com> Date: Fri, 30 Aug 2024 22:26:07 +0900 Subject: [PATCH 6/9] fix: remove unneeded code to prevent date change with `showTimeSelectOnly` Before this PR, the below function calls: `index.tsx:handleChange -> date_utils:parseDate -> date-fns:parse` were passing `new Date()` to date-fns parse function as the 'reference date' parameter. This meant that when the datepicker had the `showTimeSelectOnly` prop and the date format was just a time (e.g. H:mm) the parse result's year/month/day would be today instead of the previously selected date, which we want. To prevent this problem, the block of code being removed in the present commit would take the current selected date and reset the time to the newly input/parsed time, so that as a result, the year/month/day would not change. Instead, this PR fixes the problem at its root, by passing `this.props.selected` instead of `new Date()` to the date-fns `parse` function as the reference date, such that the parse result of just a time string will have the same year/month/day as `this.props.selected`. This is the desired behavior, and so this block of code is no longer needed. This is already tested in test "when update the datepicker input text while props.showTimeSelectOnly is set and dateFormat has only time related format". --- src/index.tsx | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 9ba444ace..afdebaefd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,7 +4,6 @@ import React, { Component, cloneElement } from "react"; import Calendar from "./calendar"; import CalendarIcon from "./calendar_icon"; import { - set, newDate, isDate, isBefore, @@ -631,7 +630,7 @@ export default class DatePicker extends Component< this.props.onChange?.([startDateNew, endDateNew], event); } else { // not selectsRange - let date = parseDate( + const date = parseDate( value, dateFormat, this.props.locale, @@ -639,20 +638,6 @@ export default class DatePicker extends Component< this.props.selected ?? undefined, ); - // Use date from `selected` prop when manipulating only time for input value - if ( - this.props.showTimeSelectOnly && - this.props.selected && - date && - !isSameDay(date, this.props.selected) - ) { - date = set(this.props.selected, { - hours: getHours(date), - minutes: getMinutes(date), - seconds: getSeconds(date), - }); - } - // Update selection if either (1) date was successfully parsed, or (2) input field is empty if (date || !value) { this.setSelected(date, event, true); From 9eee52d3ec282dc1482d28c2603e9aef5d68c467 Mon Sep 17 00:00:00 2001 From: laug <5089281+laug@users.noreply.github.com> Date: Fri, 30 Aug 2024 23:14:41 +0900 Subject: [PATCH 7/9] fix: props that have default values cannot be undefined --- src/index.tsx | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index afdebaefd..cb7e6a26b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -586,13 +586,8 @@ export default class DatePicker extends Component< lastPreSelectChange: PRESELECT_CHANGE_VIA_INPUT, }); - const { - dateFormat = DatePicker.defaultProps.dateFormat, - strictParsing = DatePicker.defaultProps.strictParsing, - selectsRange, - startDate, - endDate, - } = this.props; + const { dateFormat, strictParsing, selectsRange, startDate, endDate } = + this.props; const value = event?.target instanceof HTMLInputElement ? event.target.value : ""; @@ -603,15 +598,15 @@ export default class DatePicker extends Component< .map((val) => val.trim()); const startDateNew = parseDate( valueStart ?? "", - dateFormat, + dateFormat!, this.props.locale, - strictParsing, + strictParsing!, ); const endDateNew = parseDate( valueEnd ?? "", - dateFormat, + dateFormat!, this.props.locale, - strictParsing, + strictParsing!, ); const startChanged = startDate?.getTime() !== startDateNew?.getTime(); const endChanged = endDate?.getTime() !== endDateNew?.getTime(); @@ -632,9 +627,9 @@ export default class DatePicker extends Component< // not selectsRange const date = parseDate( value, - dateFormat, + dateFormat!, this.props.locale, - strictParsing, + strictParsing!, this.props.selected ?? undefined, ); From 6ccd4493b5e2faea22e9a47e2e82a6a08aaa8ab6 Mon Sep 17 00:00:00 2001 From: laug <5089281+laug@users.noreply.github.com> Date: Sun, 1 Sep 2024 13:51:28 +0900 Subject: [PATCH 8/9] test: fix a test that would always fail if run on first day of month --- src/test/exclude_dates.test.tsx | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/test/exclude_dates.test.tsx b/src/test/exclude_dates.test.tsx index 39a435bef..4684873f5 100644 --- a/src/test/exclude_dates.test.tsx +++ b/src/test/exclude_dates.test.tsx @@ -1,14 +1,18 @@ import { render } from "@testing-library/react"; -import { subDays } from "date-fns"; +import { addDays, subDays } from "date-fns"; import React from "react"; import DatePicker from "../index"; describe("DatePicker", () => { - const excludeDates = [new Date(), subDays(new Date(), 1)]; + const today = new Date(); + // otherDate must be in same month, otherwise it will not be shown on the calendar + const otherDate = + today.getDate() === 1 ? addDays(today, 1) : subDays(today, 1); + const excludeDates = [today, otherDate]; const excludeDatesWithMessages = [ - { date: subDays(new Date(), 1), message: "This day is excluded" }, - { date: new Date(), message: "Today is excluded" }, + { date: otherDate, message: "This day is excluded" }, + { date: today, message: "Today is excluded" }, ]; it("should disable dates specified in excludeDates props", () => { @@ -40,11 +44,11 @@ describe("DatePicker", () => { ); expect(disabledTimeItems.length).toBe(excludeDatesWithMessages.length); - expect(disabledTimeItems[0]?.getAttribute("title")).toBe( - "This day is excluded", - ); - expect(disabledTimeItems[1]?.getAttribute("title")).toBe( - "Today is excluded", - ); + expect( + disabledTimeItems[today < otherDate ? 1 : 0]?.getAttribute("title"), + ).toBe("This day is excluded"); + expect( + disabledTimeItems[today < otherDate ? 0 : 1]?.getAttribute("title"), + ).toBe("Today is excluded"); }); }); From b34bfb2d9817d35b2c0d6fb2054c3236789a3f09 Mon Sep 17 00:00:00 2001 From: laug <5089281+laug@users.noreply.github.com> Date: Thu, 5 Sep 2024 06:49:53 +0900 Subject: [PATCH 9/9] chore: remove non-null assertions --- src/index.tsx | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index cb7e6a26b..95af54497 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -586,8 +586,12 @@ export default class DatePicker extends Component< lastPreSelectChange: PRESELECT_CHANGE_VIA_INPUT, }); - const { dateFormat, strictParsing, selectsRange, startDate, endDate } = - this.props; + const { selectsRange, startDate, endDate } = this.props; + + const dateFormat = + this.props.dateFormat ?? DatePicker.defaultProps.dateFormat; + const strictParsing = + this.props.strictParsing ?? DatePicker.defaultProps.strictParsing; const value = event?.target instanceof HTMLInputElement ? event.target.value : ""; @@ -598,15 +602,15 @@ export default class DatePicker extends Component< .map((val) => val.trim()); const startDateNew = parseDate( valueStart ?? "", - dateFormat!, + dateFormat, this.props.locale, - strictParsing!, + strictParsing, ); const endDateNew = parseDate( valueEnd ?? "", - dateFormat!, + dateFormat, this.props.locale, - strictParsing!, + strictParsing, ); const startChanged = startDate?.getTime() !== startDateNew?.getTime(); const endChanged = endDate?.getTime() !== endDateNew?.getTime(); @@ -627,9 +631,9 @@ export default class DatePicker extends Component< // not selectsRange const date = parseDate( value, - dateFormat!, + dateFormat, this.props.locale, - strictParsing!, + strictParsing, this.props.selected ?? undefined, );