Skip to content

Commit

Permalink
STCOM-1094 Supply DayJS/moment conversion utilities. (#2238)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
JohnC-80 authored Mar 21, 2024
1 parent 47d679e commit 6eef47d
Show file tree
Hide file tree
Showing 11 changed files with 609 additions and 47 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* `<Selection>` 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)
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions hooks/useDynamicLocale/DynamicLocaleRenderer.js
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions hooks/useDynamicLocale/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as useDynamicLocale } from './useDynamicLocale';
export { default as DynamicLocaleRenderer } from './DynamicLocaleRenderer';

54 changes: 54 additions & 0 deletions hooks/useDynamicLocale/useDynamicLocale.js
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 7 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
51 changes: 37 additions & 14 deletions lib/Timepicker/tests/Timepicker-test.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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();
Expand All @@ -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', () => {
Expand All @@ -58,11 +79,11 @@ describe('Timepicker', () => {
<Timepicker onChange={(event) => { 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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(
<Timepicker
Expand All @@ -166,7 +187,7 @@ describe('Timepicker', () => {

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(
<TimepickerHarness
Expand All @@ -189,7 +210,7 @@ describe('Timepicker', () => {

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 = '';
Expand All @@ -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 = '';
Expand All @@ -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(
<TimepickerHarness
Expand All @@ -256,7 +279,7 @@ describe('Timepicker', () => {
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(
<TimepickerHarness
Expand All @@ -271,7 +294,7 @@ describe('Timepicker', () => {

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(
<div>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 55 additions & 0 deletions util/DateUtils_readme.md
Original file line number Diff line number Diff line change
@@ -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'
```

Loading

0 comments on commit 6eef47d

Please sign in to comment.