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:
parent
a9d9da13ad
commit
048ec11b63
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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}`)
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue