From e58b5a74caa5c76b4b45b09f0335f21887ad522d Mon Sep 17 00:00:00 2001 From: John Coburn Date: Thu, 21 Dec 2023 08:42:53 -0600 Subject: [PATCH 01/21] progress --- lib/Datepicker/Calendar.js | 379 ++++++++++++++---------- lib/Datepicker/Datepicker.js | 85 ++++-- lib/Datepicker/tests/Datepicker-test.js | 14 +- lib/Timepicker/Timepicker.js | 28 +- lib/Timepicker/tests/Timepicker-test.js | 42 ++- package.json | 1 + util/dateTimeUtils.js | 131 ++++++-- 7 files changed, 425 insertions(+), 255 deletions(-) diff --git a/lib/Datepicker/Calendar.js b/lib/Datepicker/Calendar.js index e013d392d..11d3cc68f 100644 --- a/lib/Datepicker/Calendar.js +++ b/lib/Datepicker/Calendar.js @@ -2,48 +2,70 @@ * Display calendar UI for datepicker. * Sync the cursor to the selected date or default to today. * Month is rendered based on cursor date. -* Handles date math via moment. +* Handles date math via dayjs. */ import React from 'react'; import { FormattedMessage, injectIntl } from 'react-intl'; import PropTypes from 'prop-types'; -import Moment from 'moment'; -import { extendMoment } from 'moment-range'; +import { isArray } from 'lodash'; +import moment from 'moment-timezone'; +import dayjs from 'dayjs'; +import isoWeek from 'dayjs/plugin/isoWeek'; +import weekOfYear from 'dayjs/plugin/weekOfYear'; +import weekday from 'dayjs/plugin/weekday'; +import localeData from 'dayjs/plugin/localeData'; +import arraySupport from 'dayjs/plugin/arraySupport'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; import IconButton from '../IconButton'; import MonthSelect from './MonthSelect'; - +import DynamicLocaleRenderer from '../../hooks/useDynamicLocale/DynamicLocaleRenderer'; import css from './Calendar.css'; import staticFirstWeekday from './staticFirstWeekDay'; import staticRegions from './staticLangCountryCodes'; -const moment = extendMoment(Moment); + +dayjs.extend(isoWeek); +dayjs.extend(localeData); +dayjs.extend(weekOfYear); +dayjs.extend(weekday); +dayjs.extend(arraySupport); +dayjs.extend(customParseFormat); + +const getRange = (start, end) => { + const range = []; + let current = typeof start === 'string' ? dayjs(start) : start; + while (current.isBefore(end)) { + range.push(current.clone()); + current = current.add(1, 'day'); + } + return range; +}; function getCalendar(year, month, offset = 0) { - const startDate = moment([year, month]); - const firstDay = moment(startDate).startOf('month'); - const endDay = moment(startDate).endOf('month'); - const monthRange = moment.range(firstDay, endDay); + const startDate = dayjs([year, month]); + const firstDay = startDate.startOf('month'); + const endDay = startDate.endOf('month'); + const monthRange = getRange(firstDay, endDay); const weeks = []; const calendar = []; const rowStartArray = []; const rowEndArray = []; - const weekdays = Array.from(monthRange.by('days')); - weekdays.forEach((mo) => { + monthRange.forEach((mo) => { const ref = mo.week(); if (weeks.indexOf(ref) < 0) { weeks.push(mo.week()); - const endClone = moment(mo); - rowStartArray.push(mo.weekday(offset + 0)); - rowEndArray.push(endClone.weekday(offset + 6)); + const endClone = dayjs(mo); + rowStartArray.push(mo.weekday(offset)); + rowEndArray.push(endClone.weekday(offset + 6).add(1, 'day')); } }); for (let i = 0; i < weeks.length; i += 1) { - const weekRange = moment.range(rowStartArray[i], rowEndArray[i]); + const weekRange = getRange(rowStartArray[i], rowEndArray[i]); calendar.push(weekRange); } @@ -87,8 +109,17 @@ function getFirstWeekday(locale) { return dayMap[firstDay]; } +function getAdjustedLocale(locale) { + let adjustedLocale = locale; + if (adjustedLocale.length === 2) { + const regionDefault = staticRegions[adjustedLocale]; + adjustedLocale = `${adjustedLocale}-${regionDefault}`; + } + return adjustedLocale; +} + const propTypes = { - dateFormat: PropTypes.string, + dateFormat: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), exclude: PropTypes.func, fillParent: PropTypes.bool, firstFieldRef: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), @@ -124,19 +155,22 @@ class Calendar extends React.Component { constructor(props) { super(props); - moment.locale(this.props.intl.locale || this.props.locale); - const { selectedDate, dateFormat } = this.props; - this.selectedMoment = new moment(); // eslint-disable-line new-cap + this.selectedDate = dayjs(); let cursorDate; if (!selectedDate) { - cursorDate = new moment(); // eslint-disable-line new-cap - } else if (moment(selectedDate, dateFormat, true).isValid()) { - this.selectedMoment = new moment(selectedDate, dateFormat, true); // eslint-disable-line new-cap - cursorDate = this.selectedMoment; + cursorDate = dayjs(); + } else if (dayjs(selectedDate, dateFormat, true).isValid()) { + if (selectedDate.isMoment) { + this.selectedDate = dayjs(selectedDate.toISOString(), dateFormat, true); + console.warn('Calendar component passed a MomentJS object for selected date. Migration to Dayjs is required!') + } else { + this.selectedDate = dayjs(selectedDate, dateFormat, true); + } + cursorDate = this.selectedDate; } else { // no pre-selected date, datestring invalid, init as 'today'. - cursorDate = new moment(); // eslint-disable-line new-cap + cursorDate = dayjs(); } // if the stripes locale has no region (only 2 letters), it needs to be normalized to a @@ -147,20 +181,11 @@ class Calendar extends React.Component { // but until then... let dayOffset = 0; - let adjustedLocale = props.intl.locale || props.locale; - if (adjustedLocale.length === 2) { - const regionDefault = staticRegions[adjustedLocale]; - adjustedLocale = `${adjustedLocale}-${regionDefault}`; - } + let adjustedLocale = getAdjustedLocale(props.intl.locale || props.locale); - // if moment doesn't have the requested locale from above (intl/stripes), it falls back to 'en'. If this - // is the case, we need to set an offset value for correct calendar day rendering - - // otherwise, the calendar columns will be off, resulting misaligned weekdays/calendar days. - if (moment.locale() === 'en') { - dayOffset = getFirstWeekday(adjustedLocale); - } + dayOffset = adjustedLocale === 'en' ? 0 : getFirstWeekday(adjustedLocale); - const base = new moment(cursorDate); // eslint-disable-line new-cap + const base = dayjs(cursorDate); const month = base.month(); const year = base.year(); @@ -169,10 +194,10 @@ class Calendar extends React.Component { this.state = { cursorDate, - date: this.selectedMoment, + date: this.selectedDate, month, year, - calendar: getCalendar(year, month), + calendar: getCalendar(year, month, dayOffset), dayOffset }; @@ -190,8 +215,9 @@ class Calendar extends React.Component { // When the selected date has changed, update the state with it let stateUpdate; - if (nextProps.selectedDate !== prevState.selectedDate) { - const moDate = new moment(nextProps.selectedDate, nextProps.dateFormat, true); // eslint-disable-line new-cap + if (nextProps.selectedDate && + nextProps.selectedDate !== prevState.selectedDate) { + const moDate = dayjs(nextProps.selectedDate, nextProps.dateFormat, true); if (moDate.isValid()) { if (moDate !== prevState.date) { // const moDate = moment(nextProps.selectedDate); @@ -208,7 +234,7 @@ class Calendar extends React.Component { }; } } else { // fix navigation issue on null or invalid date - const fallbackDate = new moment(); // eslint-disable-line new-cap + const fallbackDate = dayjs(); const month = fallbackDate.month(); const year = fallbackDate.year(); stateUpdate = { @@ -283,8 +309,8 @@ class Calendar extends React.Component { moveCursor = (op) => { const curDate = this.state.cursorDate; - op(curDate); // eslint-disable-line new-cap - this.updateCursorDate(curDate); + const newDate = op(curDate); + this.updateCursorDate(newDate); } focusTrap = { @@ -305,8 +331,12 @@ class Calendar extends React.Component { } return newState; }, () => { - const { id, dateFormat } = this.props; + const { id, dateFormat: dateFormatProp } = this.props; const { cursorDate } = this.state; + let dateFormat = dateFormatProp; + if (isArray(dateFormatProp)) { + dateFormat = dateFormatProp[0]; + } const cursorString = cursorDate.format(dateFormat); const nextButtonElem = document.getElementById(`datepicker-choose-date-button-${cursorString}-${id}`); nextButtonElem?.focus(); // eslint-disable-line no-unused-expressions @@ -328,15 +358,19 @@ class Calendar extends React.Component { } isDateSelected = (day) => { + const { + dateFormat + } = this.props; + const format = isArray(dateFormat) ? dateFormat[0] : dateFormat; if (this.props.selectedDate) { - return day.format(this.props.dateFormat) === - new moment(this.props.selectedDate, this.props.dateFormat, true) // eslint-disable-line new-cap - .format(this.props.dateFormat); + return day.format(format) === + new dayjs(this.props.selectedDate, dateFormat, true) // eslint-disable-line new-cap + .format(format); } if (this.state.date) { - return day.format(this.props.dateFormat) === - new moment(this.state.date, this.props.dateFormat, true) // eslint-disable-line new-cap - .format(this.props.dateFormat); + return day.format(format) === + new dayjs(this.state.date, dateFormat, true) // eslint-disable-line new-cap + .format(format); } return false; } @@ -357,10 +391,10 @@ class Calendar extends React.Component { this.setState(curState => { const { dayOffset } = curState; let cursorDate = ''; - if (month === this.selectedMoment?.month()) { - cursorDate = this.selectedMoment; + if (month === this.selectedDate?.month()) { + cursorDate = this.selectedDate; } else { - cursorDate = new moment().month(month).date(1).year(curState.year); // eslint-disable-line new-cap + cursorDate = new dayjs().month(month).date(1).year(curState.year); // eslint-disable-line new-cap } return { month, @@ -373,7 +407,7 @@ class Calendar extends React.Component { updateYear = (e) => { if (e.target.value) { const year = e.target.value; - if (new moment(year, 'YYYY', true).isValid()) { // eslint-disable-line new-cap + if (new dayjs(year, 'YYYY', true).isValid()) { // eslint-disable-line new-cap this.setState(curState => ({ year, calendar: getCalendar(year, curState.month, curState.dayOffset), @@ -403,6 +437,20 @@ class Calendar extends React.Component { }); } + handleLocaleLoaded = ({ isEnglish }) => { + const { intl, locale } = this.props; + if (!isEnglish) { + this.setState(cur => { + const adjustedLocale = getAdjustedLocale(intl.locale || locale); + const dayOffset = getFirstWeekday(adjustedLocale) + return { + calendar: getCalendar(cur.year, cur.month, dayOffset), + dayOffset: dayOffset + } + }); + } + } + render() { const { id, rootRef, fillParent, trapFocus } = this.props; @@ -412,16 +460,16 @@ class Calendar extends React.Component { (week) => { weekCount += 1; const dayList = []; - const weekDays = Array.from(week.by('days')); - weekDays.forEach((day) => { dayList.push(day); }); + week.forEach((day) => { dayList.push(day); }); const days = dayList.map( (day) => { const { month, cursorDate } = this.state; - const { intl, exclude, dateFormat } = this.props; - const dayMonth = day.month() + 1; - const isCurrentMonth = dayMonth === month + 1; - const isToday = day.isSame(moment(), 'day'); + const { intl, exclude, dateFormat: dateFormatProp } = this.props; + const dateFormat = isArray(dateFormatProp) ? dateFormatProp[0] : dateFormatProp; + const dayMonth = day.month(); + const isCurrentMonth = dayMonth === month; + const isToday = day.isSame(dayjs(), 'day'); const isSelected = this.isDateSelected(day); const isCursored = day.isSame(cursorDate, 'day'); @@ -448,7 +496,8 @@ class Calendar extends React.Component { numericDay = numericDay.replace('MM', new Intl.NumberFormat( intl.locale, { minimumIntegerDigits: 2 } - ).format(dayMonth)) + // JS months are a 0-based index, so for correctly ISO-parseable dates, 1 needs to be added. + ).format(dayMonth + 1)) .replace('YYYY', new Intl.NumberFormat(intl.locale, { style: 'decimal', useGrouping: false }) @@ -512,112 +561,114 @@ class Calendar extends React.Component { } return ( - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -
{/* eslint-disable jsx-a11y/no-noninteractive-tabindex */} - { trapFocus &&
} -
-
- - { ([ariaLabel]) => ( - this.moveDate('subtract', 'year')} - data-test-calendar-previous-year - aria-label={ariaLabel} - /> - )} - - - { ([ariaLabel]) => ( - this.moveDate('subtract', 'month')} - data-test-calendar-previous-month - aria-label={ariaLabel} - /> - )} - - - { ([ariaLabel]) => ( - - )} - - - { ([ariaLabel]) => ( - - )} - - - { ([ariaLabel]) => ( - this.moveDate('add', 'month')} - data-test-calendar-next-month - aria-label={ariaLabel} - /> - )} - - - { ([ariaLabel]) => ( - this.moveDate('add', 'year')} - data-test-calendar-next-year - aria-label={ariaLabel} - /> - )} + + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} +
{/* eslint-disable jsx-a11y/no-noninteractive-tabindex */} + {trapFocus &&
} +
+
+ + {([ariaLabel]) => ( + this.moveDate('subtract', 'year')} + data-test-calendar-previous-year + aria-label={ariaLabel} + /> + )} + + + {([ariaLabel]) => ( + this.moveDate('subtract', 'month')} + data-test-calendar-previous-month + aria-label={ariaLabel} + /> + )} + + + {([ariaLabel]) => ( + + )} + + + {([ariaLabel]) => ( + + )} + + + {([ariaLabel]) => ( + this.moveDate('add', 'month')} + data-test-calendar-next-month + aria-label={ariaLabel} + /> + )} + + + {([ariaLabel]) => ( + this.moveDate('add', 'year')} + data-test-calendar-next-year + aria-label={ariaLabel} + /> + )} + +
+
    + {daysOfWeek} +
+ + {([description]) =>
{description}
}
+ + + {weeks} + +
-
    - {daysOfWeek} -
- - { ([description]) =>
{description}
} -
- - - {weeks} - -
+ {trapFocus &&
} + {/* eslint-enable jsx-a11y/no-noninteractive-tabindex */}
- {trapFocus &&
} - {/* eslint-enable jsx-a11y/no-noninteractive-tabindex */} -
+ ); } } diff --git a/lib/Datepicker/Datepicker.js b/lib/Datepicker/Datepicker.js index dd334bbfa..0a5f52336 100644 --- a/lib/Datepicker/Datepicker.js +++ b/lib/Datepicker/Datepicker.js @@ -1,7 +1,11 @@ import React, { useState, useRef, useEffect } from 'react'; import { FormattedMessage, injectIntl } from 'react-intl'; import PropTypes from 'prop-types'; -import moment from 'moment-timezone'; +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; +import contains from 'dom-helpers/query/contains'; import uniqueId from 'lodash/uniqueId'; import pick from 'lodash/pick'; import RootCloseWrapper from '../../util/RootCloseWrapper'; @@ -13,24 +17,32 @@ import TextField from '../TextField'; import Calendar from './Calendar'; import css from './Calendar.css'; import { getLocaleDateFormat } from '../../util/dateTimeUtils'; +import { useDynamicLocale } from '../../hooks/useDynamicLocale'; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(customParseFormat); const pickDataProps = (props) => pick(props, (v, key) => key.indexOf('data-test') !== -1); -// Controls the formatting from the value prop to what displays in the UI. +const containsUTCOffset = (value) => { + const offsetRegex = /T[\d.:]+[+-][\d]+$/; + const offsetRE2 = /T[\d:]+[-+][\d:]+\d{2}$/; // sans milliseconds + return offsetRegex.test(value) || offsetRE2.test(value); +}; + +// Controls the formatting from the value prop to what displays in the text input. // need to judge the breakage factor in adopting a spread syntax for these parameters... const defaultParser = (value, timeZone, uiFormat, outputFormats) => { if (!value || value === '') { return value; } - - const offsetRegex = /T[\d.:]+[+-][\d]+$/; - const offsetRE2 = /T[\d:]+[-+][\d:]+\d{2}$/; // sans milliseconds - let inputMoment; - // if date string contains a utc offset, we can parse it as utc time and convert it to selected timezone. - if (offsetRegex.test(value) || offsetRE2.test(value)) { - inputMoment = moment.tz(value, timeZone); + let inputDate; + if (containsUTCOffset(value)) { + inputDate = dayjs.utc(value).tz(timeZone); } else { - inputMoment = moment.tz(value, [uiFormat, ...outputFormats], timeZone); + inputDate = dayjs(value, [uiFormat, ...outputFormats]); } - const inputValue = inputMoment.format(uiFormat); + + const inputValue = inputDate.format(uiFormat); return inputValue; }; @@ -55,10 +67,10 @@ const defaultParser = (value, timeZone, uiFormat, outputFormats) => { */ export const defaultOutputFormatter = ({ backendDateStandard, value, uiFormat, outputFormats, timeZone }) => { if (!value || value === '') { return value; } - const parsed = new moment.tz(value, [uiFormat, ...outputFormats], timeZone); // eslint-disable-line + const parsed = dayjs.utc(value); - if (/8601/.test(backendDateStandard)) { - return parsed.toISOString(); + if (parsed.isValid() && /8601/.test(backendDateStandard)) { + return parsed.locale('en').format('YYYY-MM-DDTHH:mm:ss.SSS[Z]'); } // Use `.locale('en')` before `.format(...)` to get Arabic/"Latn" numerals. @@ -73,16 +85,16 @@ export const defaultOutputFormatter = ({ backendDateStandard, value, uiFormat, o // https://www.rfc-editor.org/rfc/rfc5646.html // for support of the RFC2822 format (rare thus far and support may soon be deprecated.) - if (/2822/.test(backendDateStandard)) { + if (parsed.isValid() && /2822/.test(backendDateStandard)) { const DATE_RFC2822 = 'ddd, DD MMM YYYY HH:mm:ss ZZ'; - return parsed.locale('en').format(DATE_RFC2822); + return dayjs.tz(value, timeZone).locale('en').format(DATE_RFC2822); } // if a localized string dateformat has been passed, normalize the date first... // otherwise, localized strings could be submitted to the backend. - const normalizedDate = moment.utc(value, [uiFormat, ...outputFormats]); + const normalizedDate = dayjs.utc(value, [uiFormat, ...outputFormats]); - return new moment(normalizedDate, 'YYYY-MM-DD').locale('en').format(backendDateStandard); // eslint-disable-line + return dayjs(normalizedDate, 'YYYY-MM-DD').locale('en').format(backendDateStandard); // eslint-disable-line }; const propTypes = { @@ -122,7 +134,7 @@ const propTypes = { const getBackendDateStandard = (standard, use) => { if (!use) return undefined; - if (standard === 'ISO8601') return ['YYYY-MM-DDTHH:mm:ss.sssZ', 'YYYY-MM-DDTHH:mm:ssZ']; + if (standard === 'ISO8601') return ['YYYY-MM-DDTHH:mm:ss.SSSZ', 'YYYY-MM-DDTHH:mm:ssZ']; if (standard === 'RFC2822') return ['ddd, DD MMM YYYY HH:mm:ss ZZ']; return [standard, 'YYYY-MM-DDTHH:mm:ss.sssZ', 'ddd, DD MMM YYYY HH:mm:ss ZZ']; }; @@ -186,6 +198,7 @@ const Datepicker = ( outputFormats: getBackendDateStandard(backendDateStandard, true) }) : null }); + const { localeLoaded } = useDynamicLocale({ locale }); // since updating the Datepair object isn't quite enough to prompt a re-render when its only partially // updated, need to maintain a 2nd field containing only the displayed value. // this resolves issue with the clearIcon not showing up. @@ -205,7 +218,10 @@ const Datepicker = ( // handle value changes that originate outside of the component. useEffect(() => { - if (typeof valueProp !== 'undefined' && valueProp !== datePair.dateString && valueProp !== datePair.formatted) { + if (input.current + && typeof valueProp !== 'undefined' + && valueProp !== datePair.dateString + && valueProp !== datePair.formatted) { payload.current = Object.assign(payload.current, maybeUpdateValue(valueProp)); nativeChangeField(input, false, payload.current.dateString); } @@ -222,13 +238,20 @@ const Datepicker = ( return blankDates; } - // use strict mode to check validity - incomplete dates, anything not conforming to the format will be invalid + let valueMoment; const backendStandard = getBackendDateStandard(backendDateStandard, outputBackendValue); - const valueMoment = new moment(// eslint-disable-line new-cap - value, - [format, ...backendStandard], // pass array of possible formats () - true - ); + + if (containsUTCOffset(value)) { + valueMoment = dayjs(value); + } else { + // use strict mode to check validity - incomplete dates, anything not conforming to the format will be invalid + valueMoment = dayjs( + value, + [format, ...backendStandard], // pass array of possible formats () + true + ); + } + const isValid = valueMoment.isValid(); let dates; @@ -258,7 +281,7 @@ const Datepicker = ( return dates; } return {}; - } else if (value !== datePair.dateString) { + } else if (value !== datePair.dateString) { // if the date's not valid, we just update the datestring... dates = { dateString: value, formatted: '' @@ -374,7 +397,7 @@ const Datepicker = (