From 6eef47d9a267e13048a34050121c519f4d721474 Mon Sep 17 00:00:00 2001 From: John Coburn Date: Thu, 21 Mar 2024 15:46:16 -0500 Subject: [PATCH] STCOM-1094 Supply DayJS/moment conversion utilities. (#2238) * progress * timepicker converted * add dynamic locale hook, fix tests for datepicker, Calendar, timepicker * cleanup bugs in calendar - thanks SonarDad! * more SonarDad feedback on Calendar/Timepicker * re-export dayjs from stripes-components * remove commented code from Timepicker * remove duplicate imports in dateTimeUtils * one more time... eyes and webpack cache killing me * add Dayjs Range utility class * add tests for DayRange methods * remove only * export DayRange * semicolons * add inclusive parameter to DayRange#contains * add locale-loading utilities to stripes-components * remove only * expand testing for loadDayJSLocale * update date/time documentation * account for undefined timezone in datepicker * move array format parsing to dateTimeUtils * reset Datepicker, timepicker components to pre-conversion state. * fix formatting in timepicker tests * switch getLocalizedFormat back to falling back to moment * fix comments/naming * test timepicker with respect to provided timezones * Update CHANGELOG.md --- CHANGELOG.md | 1 + README.md | 12 + .../useDynamicLocale/DynamicLocaleRenderer.js | 20 ++ hooks/useDynamicLocale/index.js | 3 + hooks/useDynamicLocale/useDynamicLocale.js | 54 +++ index.js | 8 +- lib/Timepicker/tests/Timepicker-test.js | 51 ++- package.json | 1 + util/DateUtils_readme.md | 55 +++ util/dateTimeUtils.js | 315 ++++++++++++++++-- util/tests/dateUtils-test.js | 136 +++++++- 11 files changed, 609 insertions(+), 47 deletions(-) create mode 100644 hooks/useDynamicLocale/DynamicLocaleRenderer.js create mode 100644 hooks/useDynamicLocale/index.js create mode 100644 hooks/useDynamicLocale/useDynamicLocale.js create mode 100644 util/DateUtils_readme.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0552a89bc..6e6c980d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ * Add `number-generator` icon. Refs STCOM-1269. * Accordions retain their z-index after being blurred, and assume the highest z-index when focus enters them. Refs STCOM-1238. * `` should only move focus back to its control/trigger if focus within the list. Refs STCOM-1238. +* Add DayJS export and Date/Time utilities. Refs STCOM-1094/ ## [12.0.5](https://github.com/folio-org/stripes-components/tree/v12.0.4) (2024-02-28) [Full Changelog](https://github.com/folio-org/stripes-components/compare/v12.0.4...v12.0.5) diff --git a/README.md b/README.md index 9d559dc81..8000f73c8 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,18 @@ Useful recipes for UI patterns appearing in FOLIO modules. * [Show/Hide Columns in MCL](guides/patterns/ColumnSelector.stories.mdx) -- Give users the ability to select only the data they need to see. * [Accessible Routing](guides/patterns/AccessibleRouting.stories.mdx) -- Detail the approaches to implementing accessible focus management. +## Working with dates/times in UI-Modules + +We provide a handful of components and utilities for date/time functionality. + +* **Datepicker, Timepicker, DateRangeWrapper components** - UI-widgets for accepting date/time input. +* **FormattedDate, FormattedUTCDate, FormattedTime** - Cross-browser convenience components for displaying localized representations of system ISO8601 timestamps. +* [dateTimeUtils](util/DateUtils_readme.md) - A handful of utility functions for working with date/time code in application logic. +* **Hooks** + * useFormatDate - presentational date-formatting. + * useFormatTime - presentational time-formatting. + * useDynamicLocale - loading DayJS locale information within functional components (also available in component form, via `DynamicLocaleRenderer`). + ## Testing Stripes Components' tests are automated browser tests powered by [Karma](http://karma-runner.github.io) and written using diff --git a/hooks/useDynamicLocale/DynamicLocaleRenderer.js b/hooks/useDynamicLocale/DynamicLocaleRenderer.js new file mode 100644 index 000000000..7eb9d2d73 --- /dev/null +++ b/hooks/useDynamicLocale/DynamicLocaleRenderer.js @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import useDynamicLocale from './useDynamicLocale'; + +const DynamicLocaleRenderer = ({ children, onLoaded }) => { + const { localeLoaded, isEnglishLang } = useDynamicLocale(); + useEffect(() => { + if (localeLoaded) { + onLoaded({ isEnglishLang }); + } + }, [localeLoaded, onLoaded, isEnglishLang]); + return localeLoaded ? children : null; +}; + +DynamicLocaleRenderer.propTypes = { + children: PropTypes.node, + onLoaded: PropTypes.func, +}; + +export default DynamicLocaleRenderer; diff --git a/hooks/useDynamicLocale/index.js b/hooks/useDynamicLocale/index.js new file mode 100644 index 000000000..698dc5b4b --- /dev/null +++ b/hooks/useDynamicLocale/index.js @@ -0,0 +1,3 @@ +export { default as useDynamicLocale } from './useDynamicLocale'; +export { default as DynamicLocaleRenderer } from './DynamicLocaleRenderer'; + diff --git a/hooks/useDynamicLocale/useDynamicLocale.js b/hooks/useDynamicLocale/useDynamicLocale.js new file mode 100644 index 000000000..c166be2c1 --- /dev/null +++ b/hooks/useDynamicLocale/useDynamicLocale.js @@ -0,0 +1,54 @@ +import React from 'react'; +import { IntlContext } from 'react-intl'; +import { loadDayJSLocale } from '../../util/dateTimeUtils'; + +const isEnglishLang = (locale) => { + return /^en/.test(locale); +}; + +/** +* @typedef {Object} dynamicLocaleInfo +* @property {boolean} localeLoaded - whether the a new locale has loaded or not. +* @property {boolean} isEnglish - a boolean for whether or not the requested locale is English, DayJS' default, which is always loaded. +*/ + +/** + * useDynamicLocale - + * React hook that loads a DayJS locale. + * + * @returns {dynamicLocaleInfo} + * @param {Object} hookParams + * @param {String} hookParams.locale - locale string ex : 'en-SE' + * @returns {dynamicLocaleInfo} + */ +const useDynamicLocale = ({ locale : localeProp } = {}) => { + const { locale: localeContext } = React.useContext(IntlContext); + const [localeLoaded, setLocaleLoaded] = React.useState( + localeProp ? isEnglishLang(localeProp) : + isEnglishLang(localeContext) + ); + const [prevLocale, updatePrevLocale] = React.useState(localeProp || localeContext); + const locale = localeProp || localeContext; + + React.useEffect(() => { + const localeCallback = (loadedLocale, err) => { + if (!err) { + setLocaleLoaded(true); + } + }; + + loadDayJSLocale(locale, localeCallback); + }, [localeLoaded, locale, prevLocale]); + + if (locale !== prevLocale) { + updatePrevLocale(localeProp || localeContext); + setLocaleLoaded(localeProp ? isEnglishLang(localeProp) : isEnglishLang(localeContext)); + } + + return { + localeLoaded, + isEnglish: localeProp ? localeProp === 'en' : localeContext === 'en' + }; +}; + +export default useDynamicLocale; diff --git a/index.js b/index.js index 5b54d3786..9c28917fa 100644 --- a/index.js +++ b/index.js @@ -12,7 +12,6 @@ export { staticFirstWeekDay, staticLangCountryCodes } from './lib/Datepicker'; -export { getLocaleDateFormat, getLocalizedTimeFormatInfo } from './util/dateTimeUtils'; export { default as DateRangeWrapper } from './lib/DateRangeWrapper'; export { default as FormattedDate } from './lib/FormattedDate'; export { default as FormattedTime } from './lib/FormattedTime'; @@ -142,6 +141,13 @@ export { default as ExportCsv } from './lib/ExportCsv'; export { default as exportToCsv } from './lib/ExportCsv/exportToCsv'; /* utilities */ +export { + getLocaleDateFormat, + getLocalizedTimeFormatInfo, + dayjs, + DayRange, + loadDayJSLocale, +} from './util/dateTimeUtils'; export { default as RootCloseWrapper } from './util/RootCloseWrapper'; export { default as omitProps } from './util/omitProps'; export { diff --git a/lib/Timepicker/tests/Timepicker-test.js b/lib/Timepicker/tests/Timepicker-test.js index 6c601308d..63721f686 100644 --- a/lib/Timepicker/tests/Timepicker-test.js +++ b/lib/Timepicker/tests/Timepicker-test.js @@ -1,7 +1,11 @@ import React from 'react'; import { describe, beforeEach, it } from 'mocha'; import { expect } from 'chai'; -import moment from 'moment-timezone'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import localeData from 'dayjs/plugin/localeData'; +import timeZone from 'dayjs/plugin/timezone'; + import { runAxeTest, Button, @@ -22,6 +26,23 @@ import { TimeDropdown as TimepickerDropdownInteractor } from './timepicker-interactor-bt'; +dayjs.extend(localeData); +dayjs.extend(utc); +dayjs.extend(timeZone); + +// check for DST with respect to the timezone of the tests since different zones can observe DST differently. +const isDST = function (tz='UTC') { + const julDate = '2022-07-20'; + const janDate = '2022-01-01'; + + const julOffset = dayjs(julDate).tz(tz).utcOffset(); + const janOffset = dayjs(janDate).tz(tz).utcOffset(); + const currentOffset = dayjs().tz(tz).utcOffset(); + console.log(`TZ: ${tz} Current: ${currentOffset} Jan: ${janOffset} Jul: ${julOffset}`); + // If they're the same, we can assume the zone doesn't observe DST... always false. + if (janOffset === julOffset) return false; + return Math.max(julOffset, janOffset) === currentOffset; +} describe('Timepicker', () => { const timepicker = TimepickerInteractor(); @@ -45,7 +66,7 @@ describe('Timepicker', () => { }); it('displays the time dropdown', () => timeDropdown.exists()); - it('displays the current time in the time dropdown', () => timeDropdown.has({ hour: parseInt(moment().format('h'), 10), minute: parseInt(moment().format('mm'), 10) })); + it('displays the current time in the time dropdown', () => timeDropdown.has({ hour: parseInt(dayjs().format('h'), 10), minute: parseInt(dayjs().format('mm'), 10) })); }); describe('selecting a time', () => { @@ -58,11 +79,11 @@ describe('Timepicker', () => { { timeOutput = event?.target.value; }} /> ); - await timepicker.fillIn('05:00 PM'); + await timepicker.fillIn('5:00 PM'); }); it('emits an event with the time formatted as displayed', () => converge( - () => { if (timeOutput !== '05:00 PM') throw new Error(`timeOutput is "${timeOutput}", expected "05:00PM".`) } + () => { if (timeOutput !== '5:00 PM') throw new Error(`timeOutput is "${timeOutput}", expected "05:00PM".`) } )); describe('opening the time dropdown with a 12-hour value in the input', () => { @@ -110,11 +131,11 @@ describe('Timepicker', () => { 'de' ); - await timepicker.fillIn('05:00 PM'); + await timepicker.fillIn('5:00 PM'); }); it('emits an event with the time formatted as displayed', () => converge( - () => { if (timeOutput !== '05:00 PM') throw new Error(`timeOutput is "${timeOutput}", expected "05:00 PM"`); } + () => { if (timeOutput !== '5:00 PM') throw new Error(`timeOutput is "${timeOutput}", expected "5:00 PM"`); } )); describe('picking a time in a non-english locale', () => { @@ -151,7 +172,7 @@ describe('Timepicker', () => { describe('parsing a value prop with a time offset (usually from backend)', () => { const testTime = '10:00:00.000Z'; - const expectedTime = moment.utc(testTime, 'HH:mm:ss.sssZ').format('H:mm A'); + const expectedTime = dayjs.utc(testTime, 'HH:mm:ss.sssZ').format('H:mm A'); beforeEach(async () => { await mountWithContext( { describe('Application level behavior', () => { const initialTime = '10:00:00.000Z'; - const expectedTime = moment.utc(initialTime, 'HH:mm:ss.sssZ').format('H:mm A'); + const expectedTime = dayjs.utc(initialTime, 'HH:mm:ss.sssZ').format('H:mm A'); beforeEach(async () => { await mountWithContext( { describe('selecting a time with timeZone prop (Los Angeles) (RFF)', () => { const tz = 'America/Los_Angeles'; - const testTime = moment().tz(tz).isDST() ? '00:00:00.000Z' : '01:00:00.000Z'; + const testTime = isDST('America/Los_Angeles') ? '00:00:00.000Z' : '01:00:00.000Z';; let timeOutput = ''; beforeEach(async () => { timeOutput = ''; @@ -214,7 +235,8 @@ describe('Timepicker', () => { describe('selecting a time with timeZone prop (Barbados) (RFF)', () => { const tz = 'America/Barbados'; - const testTime = moment().tz(tz).isDST() ? '19:20:00.000Z' : '20:20:00.000Z'; + // Barbados doesn't observe DST, so no `isDST()` needed for this tz! + const testTime = '20:20:00.000Z'; let timeOutput; beforeEach(async () => { timeOutput = ''; @@ -239,8 +261,9 @@ describe('Timepicker', () => { describe('parsing a time with timeZone prop (Barbados) (RFF)', () => { const tz = 'America/Barbados'; - const testTime = moment().tz(tz).isDST() ? '15:20:00.000Z' : '16:20:00.000Z'; - const expectedTime = moment.tz(testTime, 'HH:mm:ss.sssZ', tz).format('h:mm A'); + // Barbados doesn't observe DST, so no `isDST()` needed for this tz! + const testTime = '16:20:00.000Z'; + const expectedTime = dayjs(testTime, 'HH:mm:ss.sssZ').tz(tz).format('h:mm A'); beforeEach(async () => { await mountWithContext( { describe('parsing a time with timeZone prop (UTC) (RFF)', () => { const tz = 'UTC'; const testTime = '15:20:00.000Z'; - const expectedTime = moment.tz(testTime, 'HH:mm:ss.sssZ', tz).format('h:mm A'); + const expectedTime = dayjs.tz(testTime, 'HH:mm:ss.sssZ', tz).format('h:mm A'); beforeEach(async () => { await mountWithContext( { describe('Timedropdown', () => { const initialTime = '10:00:00.000Z'; - const expectedTime = moment.utc(initialTime, 'HH:mm:ss.sssZ').format('H:mm A'); + const expectedTime = dayjs.utc(initialTime, 'HH:mm:ss.sssZ').format('H:mm A'); beforeEach(async () => { await mountWithContext(
diff --git a/package.json b/package.json index e58e03537..d077231d5 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "@folio/stripes-react-hotkeys": "^3.0.5", "classnames": "^2.2.5", "currency-codes": "^2.1.0", + "dayjs": "^1.11.10", "downshift": "^2.0.16", "flexboxgrid2": "^7.2.0", "hoist-non-react-statics": "^3.1.0", diff --git a/util/DateUtils_readme.md b/util/DateUtils_readme.md new file mode 100644 index 000000000..03ef4a940 --- /dev/null +++ b/util/DateUtils_readme.md @@ -0,0 +1,55 @@ +# Date/time utilities + +### Pre-extended DayJS + +To reduce overhead from repeated `extend()` calls throughout the codebase, we export a single, pre-extended dayjs. [See the Code](./dateTimeUtils.js) for the list of included extensions. + +FOLIO ui-modules *do not* need to add dayjs to their `package.json`. + +[See DayJS documentation for DayJS usage guidance.](https://day.js.org/docs/en/parse/parse) + +### DayRange Utility Class + +convenience class for validating date ranges. + +``` +new DayRange(startDayJS, endDayJS); +``` + +| method | parameters | description | +|------- | ---------- | ----------- | +| isSame | candidate(DayRange) | Returns `true` if day ranges are the same (matching start and end days.) | +| contains | candidate(datestring, DayRange, DayJS) | Returns `true` if candidate is between the start and end days. | +| overlaps | candidate(DayRange) | Returns `true` if candidate DayRange overlaps with the owning DayRange. | + +### Loading DayJS locales. + +To reduce bundle size, DayJS does not automatically bundle all static locales. UI module code should typically *NOT* have to load DayJS locale information in UI code since this is handled by `stripes-core` when a user changes the current platform locale. It will match the locale to the appropriate DayJS locale. + +``` +import { loadDayJSLocale } from '@folio/stripes/components'; + +loadDayJSLocale(intl.locale); +or +loadDayJSLocale('ru'); +or +loadDayJSLocale('en-SE'); +``` + +In addition to this, hooks and react-based utilities are also provided. + +### Locale date time format + +Obtaining locale-aware date/time format information can be performed via `getLocaleDateFormat()` ex: `DD.MM.YYYY`. It should be provided the `intl` object, or an object with a `locale` key. + +It defaults to return the long date format, but can be configured to return time formats as well, sharing configuration options with [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat#using_options) + +``` +import { getLocaleDateFormat } from '@folio/stripes/components'; + +const format = getLocaleDateFormat({ intl }); // returns 'MM/DD/YYYY' + +const timeFormat = getLocaleDateFormat({ intl, config: { hour: "numeric", + minute: "numeric" }}) // returns 'h:mm A' +``` + diff --git a/util/dateTimeUtils.js b/util/dateTimeUtils.js index 878191553..e04c33f97 100644 --- a/util/dateTimeUtils.js +++ b/util/dateTimeUtils.js @@ -1,14 +1,240 @@ import moment from 'moment-timezone'; +import dayjs from 'dayjs'; +import noop from 'lodash/noop'; +import timezone from 'dayjs/plugin/timezone'; +import localeData from 'dayjs/plugin/localeData'; +import utc from 'dayjs/plugin/utc'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; +import isoWeek from 'dayjs/plugin/isoWeek'; +import weekOfYear from 'dayjs/plugin/weekOfYear'; +import weekday from 'dayjs/plugin/weekday'; +import arraySupport from 'dayjs/plugin/arraySupport'; +import objectSupport from 'dayjs/plugin/objectSupport'; +import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; +import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; +import isBetween from 'dayjs/plugin/isBetween'; +import availableLocales from 'dayjs/locale' +dayjs.extend(timezone); +dayjs.extend(localeData); +dayjs.extend(utc); +dayjs.extend(objectSupport); +dayjs.extend(isoWeek); +dayjs.extend(weekOfYear); +dayjs.extend(weekday); +dayjs.extend(arraySupport); +dayjs.extend(customParseFormat); +dayjs.extend(isSameOrBefore); +dayjs.extend(isSameOrAfter); +dayjs.extend(isBetween); + +// export a pre-extended dayjs for consumption. +export { dayjs }; + +const DEFAULT_LOCALE = 'en-US'; + +/** DayJS range utility class. */ +export class DayRange { + /** + * Create a DayRange. + * @param {DayJS|dateString} start - the start value. + * @param {DayJS|dateString} end - the end value. + */ + constructor(start, end) { + this.start = dayjs(start); + this.end = dayjs(end); + this.isRange = true; + } + + /** + * returns an array of contained dayjs day objects. + * + * @method + * @name DayRange#asDayJSArray + * @returns {Array} of dayjs objects. + */ + asDayJSArray = () => { + const range = []; + let current = dayjs(this.start); + while (current.isBefore(this.end)) { + range.push(current.clone()); + current = current.add(1, 'day'); + } + return range; + }; + + /** + * equality check. + * + * @method + * @name DayRange#isSame + * @param { DayRange } + * @returns {Bool} + */ + isSame = (candidate) => { + return this.start.isSame(candidate.start, 'day') && this.end.isSame(candidate.end, 'day'); + }; + + /** + * returns true if candidate is fully within or equal to the range. Can be used with single dayjs objects + * or date strings as well. + * + * @method + * @name DayRange#contains + * @param { DayRange|Dayjs } + * @returns {Bool} + */ + contains = (candidate) => { + if (candidate instanceof DayRange) { + return this.isSame(candidate) || + (this.contains(candidate.start) && this.contains(candidate.end)); + } else { + return dayjs(candidate).isBetween(this.start, this.end); + } + }; + + /** + * returns true if candidate start or end is within the range, or if candidate is equal to the range. + * + * @method + * @name DayRange#overlaps + * @param { DayRange|Dayjs } + * @returns {Bool} + */ + overlaps = (candidate) => { + if (candidate instanceof DayRange) { + return this.isSame(candidate) || + this.contains(candidate.start) || + this.contains(candidate.end); + } else { + throw new Error('parameter should be a DayRange instance'); + } + }; +} + +/** + * Since Moment is still in use, we can keep this for sake of easing the transition. + * @deprecated + * + * @export + * @param {*} intl + * @returns {String} + */ export function getMomentLocalizedFormat(intl) { moment.locale(intl.locale); const format = moment.localeData()._longDateFormat.L; return format; } -// Returns a localized format. -// Format will be a string similar to YYYY.MM.DD - something that can be -// passed to moment for parsing/formatting purposes. +/** + * getDayJSLocalizedFormat + * Fallback function in case getLocaleDateFormat is unable to perform. + * + * @export + * @param {*} intl + * @returns {String} + */ +export const getDayJSLocalizedFormat = (intl) => { + dayjs.locale(intl.locale); + return dayjs.localeData().longDateFormat('L'); +}; + +/** dateCanBeParsed + * Due to some differentiating behavior between passing a single formats vs an array of formats to Dayjs.utc, + * we're implementing this utility function... + * We can probably remove this once https://github.com/iamkun/dayjs/pull/1914 is merged... + * + * @export + * @param {String} value - the date string to be validated. + * @param {Array.} formats - an array of formats to attempt parsing the value with. The first to + * parse successfully will be returned as the validFormat. + * @returns {String} +*/ +export const dateCanBeParsed = (value, formats) => ({ + isValid: formats.some((f) => dayjs.utc(value, f).isValid()), + validFormat: formats[formats.findIndex((f) => dayjs(value, f, true).isValid())] +}); + + + +/** + * getCompatibleDayJSLocale - + * Function that returns an existing DayJS locale. Returns undefined if the static locale does not exist. + * + * @param {String} locale - locale string ex : 'en-SE' + * @param {String} parentLocale - 2 character language of the locale...ex parentLocale of + */ +export const getCompatibleDayJSLocale = (locale, parentLanguage) => { + /** + * Check for availability of locales - DayJS comes with a JSON list of available locales. + * We can check against that before attempting to load. We check for the full locale + * first, followed by the language if the full locale doesn't work. + */ + let localeToLoad; + let available = availableLocales.findIndex(l => l.key === locale); + if (available !== -1) { + localeToLoad = locale; + } else { + available = availableLocales.findIndex(l => l.key === parentLanguage); + if (available !== -1) { + localeToLoad = parentLanguage; + } else { + // eslint-disable-next-line no-console + console.error(`${locale}/${parentLanguage} unavailable for DayJS`); + } + } + return localeToLoad; +}; + +/** + * loadDayJSLocale + * dynamically loads a DayJS locale and sets the global DayJS locale. + * @param {string} locale + * */ +export function loadDayJSLocale(locale, cb = noop) { + const parentLocale = locale.split('-')[0]; + // Locale loading setup for DayJS + // 'en-US' is default and always loaded, so we don't even worry about loading another if the language is English. + if (locale !== DEFAULT_LOCALE) { + const localeToLoad = getCompatibleDayJSLocale(locale, parentLocale); + + if (localeToLoad) { + import( + /* webpackChunkName: "dayjs-locale-[request]" */ + /* webpackExclude: /\.d\.ts$/ */ + `dayjs/locale/${localeToLoad}` + ).then(() => { + dayjs.locale(localeToLoad); + cb(localeToLoad); + }).catch(e => { + // eslint-disable-next-line no-console + console.error(`Error loading locale ${localeToLoad} for DayJS`, e); + cb(localeToLoad, e); + }); + } else { + // fall back to english in case a compatible locale can't be loaded. + dayjs.locale(DEFAULT_LOCALE); + cb(DEFAULT_LOCALE); + } + } else { + // set locale to english in case we're transitioning away from a non-english locale. + dayjs.locale(DEFAULT_LOCALE); + cb(DEFAULT_LOCALE); + } +} + +/** + * Returns a localized format. + * Format will be a string similar to YYYY.MM.DD - something that can be + * passed to moment/dayjs for parsing/formatting purposes. + * @export + * @param {Object} settings - + * @param {Object} settings.intl - the intl object from context + * @param {config} settings.config - sets the options for IntlDateTimeFormat. See + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat#using_options + * @returns {String} - something similar to 'YYYY-MM-DD' or 'MM/DD/YYYY' that you could provide to a date-parsing library. + */ + export const getLocaleDateFormat = ({ intl, config }) => { const tempDate = new Date('Thu May 14 2020 14:39:25 GMT-0500'); let format = ''; @@ -68,27 +294,32 @@ export const getLocaleDateFormat = ({ intl, config }) => { return format; }; -// getLocalizedTimeFormatInfo() - -// Accepts a locale like 'ar' or 'de' -// Gets an array of dayPeriods for 12 hour modes of time. The -// array items can be used as values for the day period control, -// and the array length used to determin 12 hour (2 items) or 24 hour mode (0 items). - -// example usage: -// getLocalizedTimeFormatInfo('en-SE') returns: -// { -// separator: ':', -// timeFormat: 'HH:mm', -// dayPeriods: [], // no dayPeriods means 24 hour time format. -// } - -// getLocalizedTimeFormatInfo('en-US') returns: -// { -// separator: ':', -// timeFormat: 'hh:mm A', -// dayPeriods: ['am', 'pm'], // day periods: 12 hour time format. -// } - +/** + * getLocalizedTimeFormatInfo() + * Accepts a locale like 'ar' or 'de' + * Gets an array of dayPeriods for 12 hour modes of time. The + * array items can be used as values for the day period control, + * and the array length used to determin 12 hour (2 items) or 24 hour mode (0 items). + * + * example usage: + * getLocalizedTimeFormatInfo('en-SE') returns: + * { + * separator: ':', + * timeFormat: 'HH:mm', + * dayPeriods: [], // no dayPeriods means 24 hour time format. + * } + * + * getLocalizedTimeFormatInfo('en-US') returns: + * { + * separator: ':', + * timeFormat: 'hh:mm A', + * dayPeriods: ['am', 'pm'], // day periods: 12 hour time format. + * } + * + * @export + * @param {*} locale + * @returns {{ timeFormat: string; dayPeriods: {}; }} + */ export function getLocalizedTimeFormatInfo(locale) { // Build array of time stamps for convenience. const dateArray = []; @@ -158,11 +389,17 @@ export function getLocalizedTimeFormatInfo(locale) { }; } -// parses time without DST. -// DST moves time forward an hour - so +1 to the utc offset - but thankfully, it's not in use for majority ranges. -// given 2 static sample dates that are far enough apart, you'd get one that wasn't -// in DST if it's observed in your locale. -// so we can use the non-DST date to avoid off-by-1-hour time issues. +/** Parses time without DST. + * DST moves time forward an hour - so +1 to the utc offset - but thankfully, it's not in use for majority ranges. + * given 2 static sample dates that are far enough apart, you'd get one that wasn't + * in DST if it's observed in your locale. + * so we can use the non-DST date to avoid off-by-1-hour time issues. + * + * @export + * @param {String} dateTime + * @param {String} timeFormat + * @returns {String} +*/ export function removeDST(dateTime, timeFormat) { const julDate = '2022-07-20'; const janDate = '2022-01-01'; @@ -174,3 +411,23 @@ export function removeDST(dateTime, timeFormat) { const timestring = dateTime.includes('T') ? dateTime.split('T')[1] : dateTime; return moment.utc(`${offsetDate}T${timestring}`).local().format(timeFormat); } + +/** + * Version of removeDST using DayJS + * + * @export + * @param {String} dateTime + * @param {String} timeFormat + * @returns {String} + */ +export function removeDSTDayJS(dateTime, timeFormat) { + const julDate = '2022-07-20'; + const janDate = '2022-01-01'; + + const julOffset = dayjs(julDate).utcOffset(); + const janOffset = dayjs(janDate).utcOffset(); + + const offsetDate = janOffset < julOffset ? janDate : julDate; + const timestring = dateTime.includes('T') ? dateTime.split('T')[1] : dateTime; + return dayjs.utc(`${offsetDate}T${timestring}`).local().format(timeFormat); +} diff --git a/util/tests/dateUtils-test.js b/util/tests/dateUtils-test.js index 928524de1..9ae832e53 100644 --- a/util/tests/dateUtils-test.js +++ b/util/tests/dateUtils-test.js @@ -1,6 +1,19 @@ import { beforeEach, it, describe } from 'mocha'; import { expect } from 'chai'; -import { getMomentLocalizedFormat, getLocaleDateFormat, getLocalizedTimeFormatInfo } from '../dateTimeUtils'; +import sinon from 'sinon'; +import { converge } from '@folio/stripes-testing'; +import { + getMomentLocalizedFormat, + getLocaleDateFormat, + getLocalizedTimeFormatInfo, + DayRange, + getDayJSLocalizedFormat, + dayjs, + getCompatibleDayJSLocale, + loadDayJSLocale +} from '../dateTimeUtils'; +import 'dayjs/locale/de'; + describe('Date Utilities', () => { describe('get localized format - moment fallback', () => { @@ -14,11 +27,12 @@ describe('Date Utilities', () => { }); }); - describe('get localized format - moment fallback', () => { + describe('get localized format - dayJS fallback', () => { let format; beforeEach(async () => { - format = getMomentLocalizedFormat({ locale: 'de' }); // eslint-disable-line + format = getDayJSLocalizedFormat({ locale: 'de' }); // eslint-disable-line }); + it('returns the long date format according to the passed locale', () => { expect(format).to.equal('DD.MM.YYYY'); }); @@ -90,4 +104,120 @@ describe('Date Utilities', () => { expect(timeFormat.timeFormat).to.equal('A hh:mm'); }); }); + + describe('DayRange class', () => { + const testRange = new DayRange(dayjs(), dayjs().add(7, 'days')); + it('expands to array', () => { + expect(testRange.asDayJSArray().length).equals(7); + }); + + it('isSame - queries equality (positive)', () => { + expect(testRange.isSame(new DayRange(dayjs(), dayjs().add(7, 'days')))).equals(true); + }); + + it('isSame - queries equality (negative)', () => { + expect(testRange.isSame(new DayRange(dayjs(), dayjs().add(8, 'days')))).equals(false); + }); + + it('contains - positive dayjs object', () => { + expect(testRange.contains(dayjs().add(1, 'day'))).equals(true); + }); + + it('contains - positive dayRange', () => { + expect(testRange.contains(new DayRange(dayjs().add(1, 'day'), dayjs().add(4, 'days')))).equals(true); + }); + + it('contains - negative dayjs object', () => { + expect(testRange.contains(dayjs().subtract(1, 'day'))).equals(false); + }); + + it('contains - negative dayRange', () => { + expect(testRange.contains(new DayRange(dayjs().subtract(1, 'day'), dayjs().add(4, 'days')))).equals(false); + }); + + it('overlaps - positive', () => { + expect(testRange.overlaps(new DayRange(dayjs().subtract(2, 'days'), dayjs().add(4, 'days')))).equals(true); + }); + + it('overlaps - positive (same range)', () => { + expect(testRange.overlaps(new DayRange(dayjs(), dayjs().add(8, 'days')))).equals(true); + }); + + it('overlaps - negative', () => { + expect(testRange.overlaps(new DayRange(dayjs().subtract(7, 'days'), dayjs().subtract(4, 'days')))).equals(false); + }); + }); + + describe('getCompatibleDayJSLocale()', () => { + let consoleSpy; + beforeEach(() => { + consoleSpy = sinon.spy(global.window.console, 'error'); + }); + + afterEach(() => { + consoleSpy.restore(); + }); + + it('returns the locale available for "SV"', () => { + expect(getCompatibleDayJSLocale('sv-se', 'se')).equals('se'); + expect(consoleSpy.notCalled).to.be.true; + }); + + it('returns the locale available for "en-SE"', () => { + expect(getCompatibleDayJSLocale('en-se', 'en')).equals('en'); + expect(consoleSpy.notCalled).to.be.true; + }); + + it('returns the locale available for "de"', () => { + expect(getCompatibleDayJSLocale('de', 'de')).equals('de'); + expect(consoleSpy.notCalled).to.be.true; + }); + + it('logs an error for non-existent locale. "vo"', () => { + expect(getCompatibleDayJSLocale('vo', 'fs')).equals(undefined); + expect(consoleSpy.called).to.be.true; + }); + }); + + describe('loadDayJSLocale', () => { + let localeCB = sinon.spy(); + beforeEach(() => { + localeCB.resetHistory(); + }); + + it('loads/sets locale to "de"', async () => { + loadDayJSLocale('de', localeCB); + await converge(() => { expect(localeCB.calledWith('de')).to.be.true; }); + }); + + it('attempt to loads/set locale to "nph" - fallback to "en-US"', async () => { + loadDayJSLocale('nph', localeCB); + await converge(() => { expect(localeCB.calledWith('en-US')).to.be.true; }); + }); + + it('loads 2 letter locale ("ru")', async () => { + loadDayJSLocale('ru'); + await converge(() => { expect(dayjs.locale()).equals('ru'); }); + }); + + it('loads parent language locale ("en-SE")', async () => { + loadDayJSLocale('en-SE'); + await converge(() => { expect(dayjs.locale()).equals('en') }); + }); + + it('resets locale if it is previously set to non-english locale', async () => { + loadDayJSLocale('ru'); + await converge(() => { expect(dayjs.locale()).equals('ru'); }); + loadDayJSLocale('en-US'); + await converge(() => { expect(dayjs.locale()).equals('en'); }); + }); + + it('writes error to console if locale is unavailable ("!e")', async () => { + const mockConsoleError = sinon.spy(console, 'error'); + loadDayJSLocale('!e'); + await converge(() => { expect(mockConsoleError.calledOnce).to.be.true }); + await converge(() => { expect(dayjs.locale()).equals('en') }); + mockConsoleError.restore(); + }); + }); });