From db78604bd4942b8508a994b3496410cdb6f05aa7 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Wed, 19 Jun 2024 10:37:20 +0100 Subject: [PATCH] I18n: Add utility for locale-aware duration formatting (#89351) * I18n: Add utility for locale-aware duration formatting * fix message * add fallback --- package.json | 1 + public/app/core/internationalization/dates.ts | 20 ++++++++++++ .../app/core/internationalization/index.tsx | 25 ++++++++++----- .../app/core/internationalization/types.d.ts | 13 ++++++++ public/app/features/admin/UserSessions.tsx | 4 +-- public/app/features/profile/UserSessions.tsx | 5 +-- yarn.lock | 31 +++++++++++++++++++ 7 files changed, 88 insertions(+), 11 deletions(-) create mode 100644 public/app/core/internationalization/dates.ts create mode 100644 public/app/core/internationalization/types.d.ts diff --git a/package.json b/package.json index 9f32bc39ac5..1968f5ffd78 100644 --- a/package.json +++ b/package.json @@ -244,6 +244,7 @@ "@emotion/react": "11.11.4", "@fingerprintjs/fingerprintjs": "^3.4.2", "@floating-ui/react": "0.26.16", + "@formatjs/intl-durationformat": "^0.2.4", "@glideapps/glide-data-grid": "^6.0.0", "@grafana/aws-sdk": "0.3.3", "@grafana/azure-sdk": "0.0.3", diff --git a/public/app/core/internationalization/dates.ts b/public/app/core/internationalization/dates.ts new file mode 100644 index 00000000000..429db533901 --- /dev/null +++ b/public/app/core/internationalization/dates.ts @@ -0,0 +1,20 @@ +import '@formatjs/intl-durationformat/polyfill'; + +import { getI18next } from './index'; + +export function formatDate(value: number | Date | string, format: Intl.DateTimeFormatOptions = {}): string { + if (typeof value === 'string') { + return formatDate(new Date(value), format); + } + + const i18n = getI18next(); + const dateFormatter = new Intl.DateTimeFormat(i18n.language, format); + return dateFormatter.format(value); +} + +export function formatDuration(duration: Intl.DurationInput, options: Intl.DurationFormatOptions = {}) { + const i18n = getI18next(); + + const dateFormatter = new Intl.DurationFormat(i18n.language, options); + return dateFormatter.format(duration); +} diff --git a/public/app/core/internationalization/index.tsx b/public/app/core/internationalization/index.tsx index 812fc4f83b7..f111be88b98 100644 --- a/public/app/core/internationalization/index.tsx +++ b/public/app/core/internationalization/index.tsx @@ -7,6 +7,7 @@ import { DEFAULT_LANGUAGE, NAMESPACES, VALID_LANGUAGES } from './constants'; import { loadTranslations } from './loadTranslations'; let tFunc: TFunction | undefined; +let i18nInstance: typeof i18n; export async function initializeI18n(language: string): Promise<{ language: string | undefined }> { // This is a placeholder so we can put a 'comment' in the message json files. @@ -28,7 +29,7 @@ export async function initializeI18n(language: string): Promise<{ language: stri ns: NAMESPACES, }; - let i18nInstance = i18n; + i18nInstance = i18n; if (language === 'detect') { i18nInstance = i18nInstance.use(LanguageDetector); const detection: DetectorOptions = { order: ['navigator'], caches: [] }; @@ -79,10 +80,20 @@ export const t = (id: string, defaultMessage: string, values?: Record { - if (typeof value === 'string') { - return i18nDate(new Date(value), format); +export function getI18next() { + if (!tFunc) { + if (process.env.NODE_ENV !== 'test') { + console.warn( + 'An attempt to internationalize was made before it was initialized. This was probably caused by calling a locale-aware function in the root module scope, instead of in render' + ); + } + + if (process.env.NODE_ENV === 'development') { + throw new Error('getI18next was called before i18n was initialized'); + } + + return i18n; } - const dateFormatter = new Intl.DateTimeFormat(i18n.language, format); - return dateFormatter.format(value); -}; + + return i18nInstance || i18n; +} diff --git a/public/app/core/internationalization/types.d.ts b/public/app/core/internationalization/types.d.ts new file mode 100644 index 00000000000..7a4ac64930c --- /dev/null +++ b/public/app/core/internationalization/types.d.ts @@ -0,0 +1,13 @@ +import { + DurationFormatConstructor, + DurationFormatOptions as _DurationFormatOptions, + DurationInput as _DurationInput, +} from '@formatjs/intl-durationformat/src/types'; + +declare global { + namespace Intl { + const DurationFormat: DurationFormatConstructor; + type DurationFormatOptions = _DurationFormatOptions; + type DurationInput = _DurationInput; + } +} diff --git a/public/app/features/admin/UserSessions.tsx b/public/app/features/admin/UserSessions.tsx index 465072ba34d..91f8694998b 100644 --- a/public/app/features/admin/UserSessions.tsx +++ b/public/app/features/admin/UserSessions.tsx @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react'; import { ConfirmButton, ConfirmModal, Button, Stack } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; -import { i18nDate } from 'app/core/internationalization'; +import { formatDate } from 'app/core/internationalization/dates'; import { AccessControlAction, UserSession } from 'app/types'; interface Props { @@ -68,7 +68,7 @@ class BaseUserSessions extends PureComponent { sessions.map((session, index) => ( {session.isActive ? 'Now' : session.seenAt} - {i18nDate(session.createdAt, { dateStyle: 'long' })} + {formatDate(session.createdAt, { dateStyle: 'long' })} {session.clientIp} {`${session.browser} on ${session.os} ${session.osVersion}`} diff --git a/public/app/features/profile/UserSessions.tsx b/public/app/features/profile/UserSessions.tsx index ec9dbb034e7..9d93c5a9e08 100644 --- a/public/app/features/profile/UserSessions.tsx +++ b/public/app/features/profile/UserSessions.tsx @@ -4,7 +4,8 @@ import React, { PureComponent } from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { Button, Icon, LoadingPlaceholder } from '@grafana/ui'; -import { i18nDate, Trans } from 'app/core/internationalization'; +import { Trans } from 'app/core/internationalization'; +import { formatDate } from 'app/core/internationalization/dates'; import { UserSession } from 'app/types'; interface Props { @@ -50,7 +51,7 @@ class UserSessions extends PureComponent { {sessions.map((session: UserSession, index) => ( {session.isActive ? Now : {session.seenAt}} - {i18nDate(session.createdAt, { dateStyle: 'long' })} + {formatDate(session.createdAt, { dateStyle: 'long' })} {session.clientIp} {session.browser} on {session.os} {session.osVersion} diff --git a/yarn.lock b/yarn.lock index 4529834161a..079e080a949 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2503,6 +2503,16 @@ __metadata: languageName: node linkType: hard +"@formatjs/ecma402-abstract@npm:2.0.0": + version: 2.0.0 + resolution: "@formatjs/ecma402-abstract@npm:2.0.0" + dependencies: + "@formatjs/intl-localematcher": "npm:0.5.4" + tslib: "npm:^2.4.0" + checksum: 10/41543ba509ea3c7d6530d57b888115f7ca242f13462a951fae4d1d1f28bae10c999f4dea28a71d2f08366d4889a3f5276cae3a16c6f6417b841a84fd314c2234 + languageName: node + linkType: hard + "@formatjs/fast-memoize@npm:1.2.6": version: 1.2.6 resolution: "@formatjs/fast-memoize@npm:1.2.6" @@ -2533,6 +2543,17 @@ __metadata: languageName: node linkType: hard +"@formatjs/intl-durationformat@npm:^0.2.4": + version: 0.2.4 + resolution: "@formatjs/intl-durationformat@npm:0.2.4" + dependencies: + "@formatjs/ecma402-abstract": "npm:2.0.0" + "@formatjs/intl-localematcher": "npm:0.5.4" + tslib: "npm:^2.4.0" + checksum: 10/5f500409a20d18967e17ffbc222f9b4c4bf7ef08cce20023c33f06d1989c2bc4cf700d1dd1d048748d0a36c882109d5375896a4964d6700f73ec18914c6de4ba + languageName: node + linkType: hard + "@formatjs/intl-localematcher@npm:0.2.31": version: 0.2.31 resolution: "@formatjs/intl-localematcher@npm:0.2.31" @@ -2542,6 +2563,15 @@ __metadata: languageName: node linkType: hard +"@formatjs/intl-localematcher@npm:0.5.4": + version: 0.5.4 + resolution: "@formatjs/intl-localematcher@npm:0.5.4" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/780cb29b42e1ea87f2eb5db268577fcdc53da52d9f096871f3a1bb78603b4ba81d208ea0b0b9bc21548797c941ce435321f62d2522795b83b740f90b0ceb5778 + languageName: node + linkType: hard + "@gar/promisify@npm:^1.0.1": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -17036,6 +17066,7 @@ __metadata: "@emotion/react": "npm:11.11.4" "@fingerprintjs/fingerprintjs": "npm:^3.4.2" "@floating-ui/react": "npm:0.26.16" + "@formatjs/intl-durationformat": "npm:^0.2.4" "@glideapps/glide-data-grid": "npm:^6.0.0" "@grafana/aws-sdk": "npm:0.3.3" "@grafana/azure-sdk": "npm:0.0.3"