diff --git a/src/elements/common/date/DateField.js b/src/elements/common/date/DateField.js.flow similarity index 75% rename from src/elements/common/date/DateField.js rename to src/elements/common/date/DateField.js.flow index 5f3c58ff60..9e8f9485b9 100644 --- a/src/elements/common/date/DateField.js +++ b/src/elements/common/date/DateField.js.flow @@ -1,12 +1,5 @@ -/** - * @flow - * @file Function to render the date table cell - * @author Box - */ - import * as React from 'react'; -import { injectIntl, FormattedMessage } from 'react-intl'; -import type { IntlShape } from 'react-intl'; +import { FormattedMessage, useIntl} from 'react-intl'; import { isToday, isYesterday } from '../../../utils/datetime'; import messages from '../messages'; import './DateField.scss'; @@ -14,10 +7,9 @@ import './DateField.scss'; type Props = { capitalize?: boolean, date: string, - dateFormat?: Object, - intl: IntlShape, + dateFormat?: any, omitCommas?: boolean, - relative?: boolean, + relative?: boolean }; const DEFAULT_DATE_FORMAT = { @@ -31,10 +23,10 @@ const DateField = ({ date, dateFormat = DEFAULT_DATE_FORMAT, omitCommas = false, - intl, relative = true, capitalize = false, }: Props) => { + const { formatDate } = useIntl(); const d = new Date(date); const isTodaysDate = isToday(d); const isYesterdaysDate = isYesterday(d); @@ -52,9 +44,9 @@ const DateField = ({ return Message; } - let formattedDate = intl.formatDate(d, dateFormat); + let formattedDate = formatDate(d, dateFormat); formattedDate = omitCommas ? formattedDate.replace(/,/g, '') : formattedDate; return formattedDate; }; -export default injectIntl(DateField); +export default DateField; diff --git a/src/elements/common/date/DateField.tsx b/src/elements/common/date/DateField.tsx new file mode 100644 index 0000000000..35b5107fba --- /dev/null +++ b/src/elements/common/date/DateField.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { FormatDateOptions, FormattedMessage, useIntl } from 'react-intl'; +import { isToday, isYesterday } from '../../../utils/datetime'; + +import messages from '../messages'; +import './DateField.scss'; + +const DEFAULT_DATE_FORMAT = { + weekday: 'short', + month: 'short', + year: 'numeric', + day: 'numeric', +} as const; + +export interface DateFieldProps { + capitalize?: boolean; + date: string; + dateFormat?: FormatDateOptions; + omitCommas?: boolean; + relative?: boolean; +} + +const DateField = ({ + date, + dateFormat = DEFAULT_DATE_FORMAT, + omitCommas = false, + relative = true, + capitalize = false, +}: DateFieldProps): React.ReactNode | string => { + const { formatDate } = useIntl(); + const d = new Date(date); + const isTodaysDate = isToday(d); + const isYesterdaysDate = isYesterday(d); + + if (relative && (isTodaysDate || isYesterdaysDate)) { + let Message = ; + if (isYesterdaysDate) { + Message = ; + } + + if (capitalize) { + return {Message}; + } + + return Message; + } + + let formattedDate = formatDate(d, dateFormat); + formattedDate = omitCommas ? formattedDate.replace(/,/g, '') : formattedDate; + return formattedDate; +}; + +export default DateField; diff --git a/src/elements/common/date/__tests__/DateField.test.tsx b/src/elements/common/date/__tests__/DateField.test.tsx new file mode 100644 index 0000000000..48661a4426 --- /dev/null +++ b/src/elements/common/date/__tests__/DateField.test.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { render, screen } from '../../../../test-utils/testing-library'; + +import DateField, { DateFieldProps } from '../DateField'; + +describe('elements/common/date/DateField', () => { + const renderComponent = (props: Partial = {}) => { + render(); + }; + test('renders formatted date', () => { + renderComponent(); + expect(screen.getByText('Tue, Oct 10, 2023')).toBeInTheDocument(); + }); + + test("renders today message for today's date", () => { + renderComponent({ date: new Date().toISOString(), relative: true }); + expect(screen.getByText('today')).toBeInTheDocument(); + }); + + test("renders yesterday message for yesterday's date", () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + renderComponent({ date: yesterday.toISOString(), relative: true }); + expect(screen.getByText('yesterday')).toBeInTheDocument(); + }); + + test('renders formatted date without commas', () => { + renderComponent({ omitCommas: true }); + expect(screen.getByText('Tue Oct 10 2023')).toBeInTheDocument(); + }); + + test('renders capitalized today message', () => { + renderComponent({ date: new Date().toISOString(), relative: true, capitalize: true }); + expect(screen.getByText('today')).toHaveClass('be-date-capitalize'); + }); + + test('renders capitalized yesterday message', () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + renderComponent({ date: yesterday.toISOString(), relative: true, capitalize: true }); + expect(screen.getByText('yesterday')).toHaveClass('be-date-capitalize'); + }); + + test('renders formatted date with custom format', () => { + renderComponent({ dateFormat: { year: '2-digit', month: '2-digit', day: '2-digit' } }); + expect(screen.getByText('10/10/23')).toBeInTheDocument(); + }); +}); diff --git a/src/elements/common/date/index.js.flow b/src/elements/common/date/index.js.flow new file mode 100644 index 0000000000..69e90fcd34 --- /dev/null +++ b/src/elements/common/date/index.js.flow @@ -0,0 +1 @@ +export {default} from './DateField'; diff --git a/src/elements/common/date/index.js b/src/elements/common/date/index.ts similarity index 81% rename from src/elements/common/date/index.js rename to src/elements/common/date/index.ts index cfabefaa6c..5966e1b747 100644 --- a/src/elements/common/date/index.js +++ b/src/elements/common/date/index.ts @@ -1,2 +1 @@ -// @flow export { default } from './DateField'; diff --git a/src/utils/__tests__/datetime.test.js b/src/utils/__tests__/datetime.test.ts similarity index 96% rename from src/utils/__tests__/datetime.test.js rename to src/utils/__tests__/datetime.test.ts index ea1257953a..07f64b2fc5 100644 --- a/src/utils/__tests__/datetime.test.js +++ b/src/utils/__tests__/datetime.test.ts @@ -140,7 +140,7 @@ describe('utils/datetime', () => { ['2019-01-01T09:41:56-07:00', true], ['some random string', false], ['', false], - ])('should interpret %s as a %p date', (dateString, expected) => { + ])('should interpret %s as a %p date', (dateString: string, expected: boolean) => { const date = new Date(dateString); expect(isValidDate(date)).toBe(expected); }); @@ -151,7 +151,7 @@ describe('utils/datetime', () => { const TEN_MIN_IN_MS = 600000; const date = new Date('1995-12-17T03:24:00'); const result = addTime(date, TEN_MIN_IN_MS); - expect(result.getMinutes()).toBe(34); + expect((result as Date).getMinutes()).toBe(34); }); test('should correctly add time if the date Value is a number', () => { @@ -168,7 +168,7 @@ describe('utils/datetime', () => { ['2018-06-13T01:00:00.000+01:00', '2018-06-13T07:00:00.000Z'], ['2018-06-12T23:00:00.000-0100', '2018-06-13T07:00:00.000Z'], ['2018-06-13T02:00:00.000+02', '2018-06-13T07:00:00.000Z'], - ])('should correctly convert from %s to %s', (originDateTime, expectedDateTime) => { + ])('should correctly convert from %s to %s', (originDateTime: string, expectedDateTime: string) => { const result = convertISOStringToUTCDate(originDateTime); expect(result.toISOString()).toBe(expectedDateTime); }); @@ -202,7 +202,7 @@ describe('utils/datetime', () => { // Null-conversion examples ['2018-06-13T00:00:00.000-05:45', '2018-06-13T00:00:00.000-05:45'], ['2018-06-13T00:00:00.000+34', '2018-06-13T00:00:00.000+34'], - ])('should convert %s to %s correctly', (from, to) => { + ])('should convert %s to %s correctly', (from: string, to: string) => { const input = convertISOStringtoRFC3339String(from); expect(input).toEqual(to); }); diff --git a/src/utils/datetime.js b/src/utils/datetime.js.flow similarity index 99% rename from src/utils/datetime.js rename to src/utils/datetime.js.flow index 03dc921e4e..cac050c5f2 100644 --- a/src/utils/datetime.js +++ b/src/utils/datetime.js.flow @@ -1,8 +1,3 @@ -/** - * @flow - * @file Date and time utilities - * @author Box - */ import isNaN from 'lodash/isNaN'; const MILLISECONDS_PER_SECOND = 1000; diff --git a/src/utils/datetime.ts b/src/utils/datetime.ts new file mode 100644 index 0000000000..18590b1ec6 --- /dev/null +++ b/src/utils/datetime.ts @@ -0,0 +1,251 @@ +import isNaN from 'lodash/isNaN'; + +const MILLISECONDS_PER_SECOND = 1000; +// 24 hours * 60 minutes * 60 seconds * 1000 milliseconds +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * MILLISECONDS_PER_SECOND; +// 60 sec * 1000 +const MILLISECONDS_PER_MINUTE = 60 * MILLISECONDS_PER_SECOND; + +/** + * RegExp matcher for acceptable ISO 8601 date formats w/ timezone (see below) + * Capture groups structured as follows: + * 1) the date/time portion (2018-06-13T00:00:00.000) + * 2) the milliseconds (if matched) + * 3) the timezone portion (e.g., Z, +03, -0400, +05:00) + * 4) the Z format for timezone (if matched) + * 5) the short format for timezone (if matched) + * 6) the colon-less format for timezone (if matched) + * 7) the colon long format for timezone (if matched) + */ +const RE_ISO8601_DATE = + /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?)?((Z$)|(?:[+-](?:([0-2]\d$)|([0-2]\d(?:00|30)$)|([0-2]\d:(?:00|30)$))))$/; +const ISO8601_DATETIME = 1 as const; +const ISO8601_MILLISECONDS = 2 as const; +const ISO8601_TIMEZONE = 3 as const; +const ISO8601_Z_FMT = 4 as const; +const ISO8601_SHORT_FMT = 5 as const; +const ISO8601_MEDIUM_FMT = 6 as const; +const ISO8601_LONG_FMT = 7 as const; + +/** + * Helper to normalize a date value to a date object + * @param dateValue - Date number, string, or object + * @returns {date} the normalized date object + */ +function convertToDate(dateValue: number | string | Date): Date { + return dateValue instanceof Date ? dateValue : new Date(dateValue); +} + +/** + * Converts an integer value in seconds to milliseconds. + * @param {number} seconds - The value in seconds + * @returns {number} the value in milliseconds + */ +function convertToMs(seconds: number): number { + return seconds * MILLISECONDS_PER_SECOND; +} + +/** + * Checks whether the given date value (in unix milliseconds) is today. + * @param {number|string|Date} dateValue - Date object or integer representing the number of milliseconds since 1/1/1970 UTC + * @returns {boolean} whether the given value is today + */ +function isToday(dateValue: number | string | Date): boolean { + return new Date().toDateString() === convertToDate(dateValue).toDateString(); +} + +/** + * Checks whether the given date value (in unix milliseconds) is yesterday. + * @param {number|string|Date} dateValue - Date object or integer or representing the number of milliseconds since 1/1/1970 UTC + * @returns {boolean} whether the given value is yesterday + */ +function isYesterday(dateValue: number | string | Date): boolean { + return isToday(convertToDate(dateValue).getTime() + MILLISECONDS_PER_DAY); +} + +/** + * Checks whether the given date value (in unix milliseconds) is tomorrow. + * @param {number|string|Date} dateValue - Date object or integer or representing the number of milliseconds since 1/1/1970 UTC + * @returns {boolean} whether the given value is tomorrow + */ +function isTomorrow(dateValue: number | string | Date): boolean { + return isToday(convertToDate(dateValue).getTime() - MILLISECONDS_PER_DAY); +} + +/** + * Checks whether the given date value (in unix milliseconds) is in the current month. + * @param {number|string|Date} dateValue - Date object or integer representing the number of milliseconds since 1/1/1970 UTC + * @returns {boolean} whether the given value is in the current month + */ +function isCurrentMonth(dateValue: number | string | Date): boolean { + return new Date().getMonth() === convertToDate(dateValue).getMonth(); +} + +/** + * Checks whether the given date value (in unix milliseconds) is in the current year. + * @param {number|string|Date} dateValue - Date object or integer representing the number of milliseconds since 1/1/1970 UTC + * @returns {boolean} whether the given value is in the current year + */ +function isCurrentYear(dateValue: number | string | Date): boolean { + return new Date().getFullYear() === convertToDate(dateValue).getFullYear(); +} + +/** + * Formats a number of seconds as a time string + * + * @param {number} seconds - seconds + * @return {string} a string formatted like 3:57:35 + */ +function formatTime(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor((seconds % 3600) % 60); + const hour = h > 0 ? `${h.toString()}:` : ''; + const sec = s < 10 ? `0${s.toString()}` : s.toString(); + let min = m.toString(); + if (h > 0 && m < 10) { + min = `0${min}`; + } + return `${hour}${min}:${sec}`; +} + +/** + * Adds time to a given dateValue + * + * @param {number|Date} dateValue - date or integer value to add time to + * @param {number} timeToAdd - amount of time to add in ms + * @return {number|Date} the modified date or integer + */ +function addTime(dateValue: number | Date, timeToAdd: number): number | Date { + if (dateValue instanceof Date) { + return new Date(dateValue.getTime() + timeToAdd); + } + + return dateValue + timeToAdd; +} + +/** + * Will convert + * 2018-06-13T07:00:00.000Z + * to + * 2018-06-13T00:00:00.000Z + * + * This is the opposite of convertISOStringToUTCDate + * + * @param {Date} date + * @return {number} + */ +function convertDateToUnixMidnightTime(date: Date) { + // date is localized to 00:00:00 at system/browser timezone + const utcUnixTimeInMs = date.getTime(); + + // timezone an integer offset; minutes behind GMT + // we use the browser timezone offset instead of the user's, + // because the datepicker uses the browser to get the "midnight" + // time in the user's timezone with getTime() + const timezoneOffsetInMins = date.getTimezoneOffset(); + const timezoneOffsetInMs = timezoneOffsetInMins * MILLISECONDS_PER_MINUTE; + + // we need the unix/epoch time for midnight on the date selected + const unixDayMidnightTime = utcUnixTimeInMs - timezoneOffsetInMs; + return unixDayMidnightTime; +} + +/** + * Will check to see if a date object is not valid, according to the browser + * JS engine. + * + * @param {Date} date + * @return {boolean} whether the date value passes validation + */ +function isValidDate(date: Date): boolean { + return !isNaN(date.getTime()); +} + +/** + * Will convert ISO8601-compatible dates (with zone designators) + * 2018-06-13T00:00:00.000-0500 + * or + * 2018-06-13T00:00:00.000-05 + * + * to + * 2018-06-13T00:00:00.000-05:00 + * + * Equivalent formats between the two (e.g., uzing 'Z') will remain unchanged. + * If the date format cannot be converted, it will pass along the existing value + * @param {string} isoString + * @return {string} converted date format, if applicable + */ +function convertISOStringtoRFC3339String(isoString: string): string { + // test that the date format inbound is ISO8601-compatible + if (RE_ISO8601_DATE.test(isoString)) { + // if it is, parse out the timezone part if it's in a longer format + // use the capture groups instead of the split result for the datetime and the time zone + const parseDate = isoString.split(RE_ISO8601_DATE); + let dateTime = parseDate[ISO8601_DATETIME]; + const milliseconds = parseDate[ISO8601_MILLISECONDS]; + const timeZone = parseDate[ISO8601_TIMEZONE]; + + // add milliseconds if missing, to standardize output + if (!milliseconds) { + dateTime += '.000'; + } + + if (parseDate[ISO8601_Z_FMT]) { + return isoString; + } + + if (parseDate[ISO8601_SHORT_FMT]) { + return `${dateTime + timeZone}:00`; + } + + if (parseDate[ISO8601_MEDIUM_FMT]) { + return `${dateTime + timeZone.substr(0, 3)}:${timeZone.substr(3)}`; + } + + if (parseDate[ISO8601_LONG_FMT]) { + return isoString; + } + } + return isoString; +} + +/** + * Will convert + * 2018-06-13T00:00:00.000Z + * to + * 2018-06-13T07:00:00.000Z + * + * This is the opposite of convertDateToUnixMidnightTime + * + * @param {string} isoString - ISO string in UTC time zone + */ +function convertISOStringToUTCDate(isoString: string): Date { + // get date in UTC midnight time + const utcDate = new Date(convertISOStringtoRFC3339String(isoString)); + const utcTime = utcDate.getTime(); + + // get browser's timezone + const timezoneOffsetInMins = utcDate.getTimezoneOffset(); + const timezoneOffsetInMs = timezoneOffsetInMins * MILLISECONDS_PER_MINUTE; + + // return date in utc timezone + const localizedUnixTimeInMs = utcTime + timezoneOffsetInMs; + return new Date(localizedUnixTimeInMs); +} + +export { + convertToDate, + convertToMs, + convertDateToUnixMidnightTime, + convertISOStringToUTCDate, + convertISOStringtoRFC3339String, + isToday, + isTomorrow, + isValidDate, + isYesterday, + isCurrentMonth, + isCurrentYear, + formatTime, + addTime, +};