Start using Intl for I18n formatting

Closes FOO-1155
flag=none

Now that browser Intl support is common, we want to try to
start using it instead of things like moment.js for
internationalization of number and time formatting.

Creates a helper function to express relative time distances
(i.e. "5 days from now") which Intl.RelativeTimeFormat only
partially implements.

Creates a helper function to polyfill Intl functions for Jest
tests, since node.js currently does not implement them.

As a proof of concept, rolled all of that into the QRMobileLogin
component, which is now free of moment.js!

Test plan:
* You like the helper functions I added, especially the polyfill
* QRMobileLogin component still works fine

Change-Id: I3e31e788f8f5fb1312fba9f05815372ddcd1ae08
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/251518
Reviewed-by: Ed Schiebel <eschiebel@instructure.com>
QA-Review: Charley Kline <ckline@instructure.com>
Product-Review: Charley Kline <ckline@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
This commit is contained in:
Charley Kline 2020-10-29 18:58:59 -05:00
parent a9d9da13ad
commit 048ec11b63
6 changed files with 376 additions and 24 deletions

View File

@ -29,12 +29,12 @@ import {Modal} from '@instructure/ui-modal'
import {Button, CloseButton} from '@instructure/ui-buttons'
import {Text} from '@instructure/ui-text'
import {View} from '@instructure/ui-view'
import moment from 'moment'
import {object, bool} from 'prop-types'
import {fromNow} from 'jsx/shared/datetime/fromNowFuzzy'
import {number, bool} from 'prop-types'
const REFRESH_INTERVAL = moment.duration(9.75, 'minutes') // 9 min 45 sec
const POLL_INTERVAL = moment.duration(5, 'seconds')
const QR_CODE_LIFETIME = moment.duration(10, 'minutes')
const REFRESH_INTERVAL = 1000 * (9 * 60 + 45) // 9 min 45 sec
const POLL_INTERVAL = 1000 * 5 // 5 sec
const QR_CODE_LIFETIME = 1000 * 10 * 60 // 10 minutes
const DISPLAY_STATE = {
canceled: 0,
@ -123,9 +123,12 @@ export function QRMobileLogin({refreshInterval, pollInterval, withWarning}) {
function displayValidFor(expireTime) {
if (expireTime) validUntil = expireTime
if (validUntil) {
const newValidFor = moment().isBefore(validUntil)
? I18n.t('This code expires in %{timeFromNow}.', {timeFromNow: validUntil.fromNow(true)})
: I18n.t('This code has expired.')
const newValidFor =
Date.now() < validUntil
? I18n.t('This code expires %{timeFromNow}.', {
timeFromNow: fromNow(validUntil)
})
: I18n.t('This code has expired.')
setValidFor(newValidFor)
}
}
@ -134,8 +137,8 @@ export function QRMobileLogin({refreshInterval, pollInterval, withWarning}) {
isFetching = true
try {
const {json} = await doFetchApi({path: '/canvas/login.png', method: 'POST'})
displayValidFor(moment().add(QR_CODE_LIFETIME))
refetchAt = moment().add(refreshInterval)
displayValidFor(Date.now() + QR_CODE_LIFETIME)
refetchAt = Date.now() + refreshInterval
setImagePng(json.png)
} catch (err) {
showFlashAlert({
@ -149,8 +152,8 @@ export function QRMobileLogin({refreshInterval, pollInterval, withWarning}) {
function poll() {
displayValidFor()
if (!isFetching && (!refetchAt || moment().isAfter(refetchAt))) getQRCode()
timerId = setTimeout(poll, pollInterval.asMilliseconds())
if (!isFetching && (!refetchAt || Date.now() > refetchAt)) getQRCode()
timerId = setTimeout(poll, pollInterval)
}
if (display === DISPLAY_STATE.displayed) poll()
@ -245,8 +248,8 @@ const flexViewProps = {
}
QRMobileLogin.propTypes = {
refreshInterval: object,
pollInterval: object,
refreshInterval: number,
pollInterval: number,
withWarning: bool
}

View File

@ -18,9 +18,11 @@
import React from 'react'
import MockDate from 'mockdate'
import fetchMock from 'fetch-mock'
import moment from 'moment'
import {render, act, fireEvent} from '@testing-library/react'
import {QRMobileLogin} from '../QRMobileLogin'
import {installIntlPolyfills} from 'jsx/shared/helpers/IntlPolyFills'
const MINUTES = 1000 * 60
// a fake QR code image, and then a another one after generating a new code
const loginImageJsons = [
@ -34,6 +36,8 @@ const route = '/canvas/login.png'
const doNotRespond = Function.prototype
describe('QRMobileLogin', () => {
beforeAll(installIntlPolyfills)
describe('before the API call responds', () => {
beforeEach(() => {
fetchMock.post(route, doNotRespond, {overwriteRoutes: true})
@ -68,8 +72,7 @@ describe('QRMobileLogin', () => {
})
// advances both global time and the jest timers by the given time duration
function advance(...args) {
const delay = moment.duration(...args).asMilliseconds()
function advance(delay) {
act(() => {
const now = Date.now()
MockDate.set(now + delay)
@ -87,32 +90,32 @@ describe('QRMobileLogin', () => {
it('updates the expiration as time elapses', async () => {
const {findByText} = render(<QRMobileLogin />)
await findByText(/expires in 10 minutes/i)
advance(1, 'minute')
advance(1 * MINUTES)
const expiresIn = await findByText(/expires in 9 minutes/i)
expect(expiresIn).toBeInTheDocument()
})
it('shows the right thing when the token has expired', async () => {
const refreshInterval = moment.duration(15, 'minutes')
const pollInterval = moment.duration(3, 'minutes')
const refreshInterval = 15 * MINUTES
const pollInterval = 3 * MINUTES
const {findByText} = render(
<QRMobileLogin refreshInterval={refreshInterval} pollInterval={pollInterval} />
)
await findByText(/expires in 10 minutes/)
advance(11, 'minutes') // code is only good for 10
advance(11 * MINUTES) // code is only good for 10
const expiresIn = await findByText(/code has expired/i)
expect(expiresIn).toBeInTheDocument()
})
it('refreshes the code at the right time', async () => {
const refreshInterval = moment.duration(2, 'minutes')
const refreshInterval = 2 * MINUTES
const {findByText, findByTestId} = render(<QRMobileLogin refreshInterval={refreshInterval} />)
const image = await findByTestId('qr-code-image')
expect(image.src).toBe(`data:image/png;base64, ${loginImageJsons[0].png}`)
expect(fetchMock.calls(route)).toHaveLength(1)
advance(1, 'minute')
advance(1 * MINUTES)
await findByText(/expires in 9 minutes/)
advance(1, 'minute')
advance(1 * MINUTES)
await findByText(/expires in 10 minutes/)
expect(fetchMock.calls(route)).toHaveLength(2)
expect(image.src).toBe(`data:image/png;base64, ${loginImageJsons[1].png}`)

View File

@ -0,0 +1,80 @@
/* Copyright (C) 2020 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {fromNow} from '../fromNowFuzzy'
import {installIntlPolyfills} from 'jsx/shared/helpers/IntlPolyFills'
describe('fromNowFuzzy::', () => {
beforeAll(installIntlPolyfills)
let now
beforeEach(() => {
now = Date.now()
})
const thence = msec => new Date(now + msec)
const thenceSec = sec => thence(1000 * sec)
const thenceMin = min => thenceSec(60 * min)
const thenceHour = hour => thenceMin(60 * hour)
const thenceDay = day => thenceHour(24 * day)
const thenceYear = year => thenceDay(365.25 * year)
// fromNow accepts either a Date object in the past or future,
// or a numeric value of milliseconds representing same
it('throws on bad arguments', () => {
expect(() => fromNow('junk')).toThrow()
expect(() => fromNow(new Date('junk'))).toThrow()
expect(() => fromNow(new Date('2020-03-15T12:34:56Z'))).not.toThrow()
expect(() => fromNow(1584275696000)).not.toThrow()
})
it('handles msec values too', () => {
const inAFewSeconds = now + 5000
expect(fromNow(inAFewSeconds)).toBe('in a few seconds')
})
it('deals with things close to now', () => {
expect(fromNow(thence(0))).toBe('now')
expect(fromNow(thence(100))).toBe('now')
expect(fromNow(thence(-100))).toBe('now')
})
it('deals with things within a minute of now', () => {
expect(fromNow(thence(5000))).toBe('in a few seconds')
expect(fromNow(thence(-5000))).toBe('a few seconds ago')
expect(fromNow(thence(50000))).toBe('in less than a minute')
expect(fromNow(thence(-50000))).toBe('less than a minute ago')
})
it('finds the smallest unit to express', () => {
expect(fromNow(thenceMin(5))).toBe('in 5 minutes')
expect(fromNow(thenceMin(-50))).toBe('50 minutes ago')
expect(fromNow(thenceDay(3))).toBe('in 3 days')
expect(fromNow(thenceDay(-2))).toBe('2 days ago')
})
it('rounds to the nearest unit based on the next-smallest unit', () => {
expect(fromNow(thenceHour(25))).toBe('tomorrow')
expect(fromNow(thenceHour(23))).toBe('in 23 hours')
expect(fromNow(thenceHour(40))).toBe('in 2 days')
expect(fromNow(thenceDay(-13))).toBe('2 weeks ago')
})
it('will not go past weeks as a unit', () => {
expect(fromNow(thenceYear(1))).toBe('in 52 weeks')
expect(fromNow(thenceYear(-2))).toBe('104 weeks ago')
})
})

View File

@ -0,0 +1,103 @@
/*
* Copyright (C) 2020 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import I18n from 'i18n!fromNowFuzzy'
const TIME_UNITS = Object.freeze([
1000, // msec in a second
60, // seconds in a minute
60, // minutes in an hour
24, // hours in a day
7 // days in a week
])
const UNIT_NAMES = Object.freeze([
'millisecond', // not used, Intl does not understand it
'second',
'minute',
'hour',
'day',
'week'
])
function buildTime(msec) {
const negative = msec < 0
// Just because it's never reassigned doesn't mean it's not mutated a lot
// eslint-disable-next-line prefer-const
let result = [negative]
let remainder = negative ? -msec : msec
TIME_UNITS.forEach(d => {
result.push(Math.floor(remainder % d))
remainder /= d
})
result.push(Math.floor(remainder))
return result
}
function timeDistance(times, opts) {
const {locale, ...intlOpts} = opts
if (times.length !== TIME_UNITS.length + 2) {
throw new Error('argument must be buildTime array')
}
// Just because it's never reassigned doesn't mean it's not mutated a lot
/* eslint-disable prefer-const */
let units = TIME_UNITS.slice().reverse()
let unitArray = times.slice().reverse()
let unitNames = UNIT_NAMES.slice().reverse()
/* eslint-enable prefer-const */
const negative = unitArray.pop()
const rtf = new Intl.RelativeTimeFormat(locale || ENV.LOCALE || navigator.language, {
style: 'long',
numeric: 'auto',
...intlOpts
})
while (unitArray.length > 1 && unitArray[0] === 0) {
unitArray.shift()
unitNames.shift()
units.shift()
}
// if only milliseconds are left, just call it "now"
if (unitArray.length < 2) return rtf.format(0, 'second')
// otherwise, if only seconds are left, return a friendly value
if (unitArray.length === 2) {
if (unitArray[0] <= 20)
return negative ? I18n.t('a few seconds ago') : I18n.t('in a few seconds')
return negative ? I18n.t('less than a minute ago') : I18n.t('in less than a minute')
}
// otherwise round up the biggest unit and use that
const value = Math.round(unitArray[0] + unitArray[1] / units[0]) * (negative ? -1 : 1)
return rtf.format(value, unitNames[0])
}
export function fromNow(date, opts = {}) {
const now = Date.now()
let thence
if (date instanceof Date) {
thence = date.getTime()
if (Number.isNaN(thence)) throw new Error('argument Date is invalid')
} else if (typeof date === 'number') {
thence = date
} else {
throw new Error('argument must be Date object or numeric msec')
}
return timeDistance(buildTime(thence - now), opts)
}

View File

@ -0,0 +1,69 @@
/*
* Copyright (C) 2020 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
// Node versions < 13 do not come with implementations of ICU, so window.Intl
// is just an empty object. Since we are making some use of it, we need to add
// some limited-function polyfills so that Jest tests can run. This file can be
// added to as needed, and (hopefully) someday removed entirely when the native
// functions become available to Jest.
// All of these only implement the en-US locale, as do most of our tests anyway.
//
// RelativeTimeFormat converts a relative time offset in milliseconds, and a
// time unit (second, minute, etc) into a text string.
//
// This implements numeric: 'auto' but not style: 'short'
//
class RelativeTimeFormat {
constructor(locale, opts = {}) {
this.locale = locale
this.numeric = opts.numeric || 'always'
this.style = opts.style || 'long'
this.units = {
second: [null, 'now', null],
minute: [null, 'this minute', null],
hour: [null, 'this hour', null],
day: ['yesterday', 'today', 'tomorrow'],
week: ['last week', 'this week', 'next week'],
month: ['last month', 'this month', 'next month'],
year: ['last year', 'this year', 'next year']
}
}
format(num, units) {
if (!Object.keys(this.units).includes(units))
throw new RangeError(`Invalid unit argument ${units}'`)
const roundNum = Math.round(num * 1000) / 1000
if (this.numeric === 'auto' && [-1, 0, 1].includes(roundNum)) {
const result = this.units[units][roundNum + 1]
if (result) return result
}
if (roundNum === 1) return `in ${roundNum} ${units}`
if (roundNum === -1) return `${-roundNum} ${units} ago`
if (num >= 0) return `in ${roundNum} ${units}s`
return `${-roundNum} ${units}s ago`
}
}
export function installIntlPolyfills() {
if (typeof window.Intl === 'undefined') window.Intl = {}
if (typeof window.Intl.RelativeTimeFormat === 'undefined')
window.Intl.RelativeTimeFormat = RelativeTimeFormat
}

View File

@ -0,0 +1,94 @@
/* Copyright (C) 2020 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {installIntlPolyfills} from '../IntlPolyFills'
describe('IntlPolyFills::', () => {
let f
beforeAll(installIntlPolyfills)
describe('RelativeTimeFormat', () => {
it('implements format', () => {
f = new Intl.RelativeTimeFormat()
expect(f.format).toBeInstanceOf(Function)
})
it('throws on a bad unit', () => {
f = new Intl.RelativeTimeFormat()
expect(() => f.format(1, 'nonsense')).toThrow(RangeError)
})
describe('for numeric=always', () => {
beforeEach(() => {
f = new Intl.RelativeTimeFormat()
})
it('handles singular units in the past and future', () => {
expect(f.format(1, 'hour')).toBe('in 1 hour')
expect(f.format(-1, 'hour')).toBe('1 hour ago')
})
it('handles plural units in the past and future', () => {
expect(f.format('5', 'week')).toBe('in 5 weeks')
expect(f.format(-5, 'week')).toBe('5 weeks ago')
})
it('rounds off to 3 decimal places', () => {
expect(f.format(2.34567, 'day')).toBe('in 2.346 days')
expect(f.format(-9.87654, 'week')).toBe('9.877 weeks ago')
expect(f.format(0.0002, 'day')).toBe('in 0 days')
expect(f.format(-0.0002, 'day')).toBe('0 days ago')
})
})
describe('for numeric=auto', () => {
beforeEach(() => {
f = new Intl.RelativeTimeFormat('en-US', {numeric: 'auto'})
})
it('handles plural units in the past and future', () => {
expect(f.format('5', 'year')).toBe('in 5 years')
expect(f.format(-5, 'year')).toBe('5 years ago')
})
it('switches to relative phrases for -1, 0, and 1', () => {
expect(f.format(-1, 'day')).toBe('yesterday')
expect(f.format(0, 'day')).toBe('today')
expect(f.format(1, 'day')).toBe('tomorrow')
})
it('sticks with singular units when there is no relative phrase', () => {
expect(f.format(-1, 'second')).toBe('1 second ago')
expect(f.format(0, 'second')).toBe('now')
expect(f.format(1, 'second')).toBe('in 1 second')
})
it('is not tempted by things that are close to 1 or 0', () => {
expect(f.format(-1.2, 'week')).toBe('1.2 weeks ago')
expect(f.format(0.5, 'day')).toBe('in 0.5 days')
expect(f.format(1.01, 'week')).toBe('in 1.01 weeks')
})
it('deals with roundoff of stuff VERY close to 1 or 0', () => {
expect(f.format(0.0002, 'second')).toBe('now')
expect(f.format(1.0002, 'day')).toBe('tomorrow')
expect(f.format(-1.0002, 'week')).toBe('last week')
})
})
})
})