From 23ecb9d904bacf4789fbfae2838d2751b2844180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Mon, 19 May 2025 13:24:33 +0200 Subject: [PATCH] i18n: Use `grafana/i18n` to init the `locale` and manage the `regionalFormat` (#105281) --- packages/grafana-i18n/package.json | 3 ++ packages/grafana-i18n/src/dates.ts | 45 +++++++++++++++++++ packages/grafana-i18n/src/i18n.tsx | 9 ++++ packages/grafana-i18n/src/index.ts | 1 + .../grafana-i18n/src/types/dates.d.ts | 0 packages/grafana-ui/package.json | 1 + .../DateTimePickers/TimeRangePicker/mapper.ts | 26 ++++++++++- pkg/expr/sql/dummy_arm.go | 2 +- public/app/app.ts | 20 ++++++--- public/app/core/internationalization/dates.ts | 1 - public/app/types/intl.d.ts | 13 ++++++ yarn.lock | 4 ++ 12 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 packages/grafana-i18n/src/dates.ts rename public/app/core/internationalization/types.d.ts => packages/grafana-i18n/src/types/dates.d.ts (100%) create mode 100644 public/app/types/intl.d.ts diff --git a/packages/grafana-i18n/package.json b/packages/grafana-i18n/package.json index f1f13199427..e24446833ab 100644 --- a/packages/grafana-i18n/package.json +++ b/packages/grafana-i18n/package.json @@ -49,9 +49,12 @@ "postpack": "mv package.json.bak package.json" }, "dependencies": { + "@formatjs/intl-durationformat": "^0.7.0", + "fast-deep-equal": "^3.1.3", "i18next": "^24.0.0", "i18next-browser-languagedetector": "^8.0.0", "i18next-pseudo": "^2.2.1", + "micro-memoize": "^4.1.2", "react-i18next": "^15.0.0" }, "devDependencies": { diff --git a/packages/grafana-i18n/src/dates.ts b/packages/grafana-i18n/src/dates.ts new file mode 100644 index 00000000000..db699e871ae --- /dev/null +++ b/packages/grafana-i18n/src/dates.ts @@ -0,0 +1,45 @@ +import deepEqual from 'fast-deep-equal'; +import memoize from 'micro-memoize'; + +const deepMemoize: typeof memoize = (fn) => memoize(fn, { isEqual: deepEqual }); + +let regionalFormat: string | undefined; + +const createDateTimeFormatter = deepMemoize((locale: string | undefined, options: Intl.DateTimeFormatOptions) => { + return new Intl.DateTimeFormat(locale, options); +}); + +const createDurationFormatter = deepMemoize((locale: string | undefined, options: Intl.DurationFormatOptions) => { + return new Intl.DurationFormat(locale, options); +}); + +export const formatDate = deepMemoize( + (_value: number | Date | string, format: Intl.DateTimeFormatOptions = {}): string => { + const value = typeof _value === 'string' ? new Date(_value) : _value; + const dateFormatter = createDateTimeFormatter(regionalFormat, format); + return dateFormatter.format(value); + } +); + +export const formatDuration = deepMemoize( + (duration: Intl.DurationInput, options: Intl.DurationFormatOptions = {}): string => { + const dateFormatter = createDurationFormatter(regionalFormat, options); + return dateFormatter.format(duration); + } +); + +export const formatDateRange = ( + _from: number | Date | string, + _to: number | Date | string, + format: Intl.DateTimeFormatOptions = {} +): string => { + const from = typeof _from === 'string' ? new Date(_from) : _from; + const to = typeof _to === 'string' ? new Date(_to) : _to; + + const dateFormatter = createDateTimeFormatter(regionalFormat, format); + return dateFormatter.formatRange(from, to); +}; + +export const initRegionalFormat = (regionalFormatArg: string) => { + regionalFormat = regionalFormatArg; +}; diff --git a/packages/grafana-i18n/src/i18n.tsx b/packages/grafana-i18n/src/i18n.tsx index 02f2396c403..990f013d3a2 100644 --- a/packages/grafana-i18n/src/i18n.tsx +++ b/packages/grafana-i18n/src/i18n.tsx @@ -4,6 +4,7 @@ import LanguageDetector, { DetectorOptions } from 'i18next-browser-languagedetec import { initReactI18next, setDefaults, setI18n, Trans as I18NextTrans, getI18n } from 'react-i18next'; import { DEFAULT_LANGUAGE, PSEUDO_LOCALE } from './constants'; +import { initRegionalFormat } from './dates'; import { LANGUAGES } from './languages'; import { TransProps, TransType } from './types'; @@ -123,6 +124,14 @@ export async function changeLanguage(language?: string) { await getI18nInstance().changeLanguage(validLanguage); } +export async function initializeI18n( + { language, ns, module }: InitializeI18nOptions, + regionalFormat: string +): Promise<{ language: string | undefined }> { + initRegionalFormat(regionalFormat); + return initTranslations({ language, ns, module }); +} + type ResourceKey = string; type ResourceLanguage = Record; type ResourceType = Record; diff --git a/packages/grafana-i18n/src/index.ts b/packages/grafana-i18n/src/index.ts index e2e7e3c3d60..c9d6f57056c 100644 --- a/packages/grafana-i18n/src/index.ts +++ b/packages/grafana-i18n/src/index.ts @@ -24,3 +24,4 @@ export { } from './constants'; export { initPluginTranslations, Trans, useTranslate } from './i18n'; export type { TransProps } from './types'; +export { formatDate, formatDuration, formatDateRange } from './dates'; diff --git a/public/app/core/internationalization/types.d.ts b/packages/grafana-i18n/src/types/dates.d.ts similarity index 100% rename from public/app/core/internationalization/types.d.ts rename to packages/grafana-i18n/src/types/dates.d.ts diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index ad17eb046dc..4df077a834a 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -69,6 +69,7 @@ "@grafana/data": "12.1.0-pre", "@grafana/e2e-selectors": "12.1.0-pre", "@grafana/faro-web-sdk": "^1.13.2", + "@grafana/i18n": "12.1.0-pre", "@grafana/schema": "12.1.0-pre", "@hello-pangea/dnd": "17.0.0", "@leeoniya/ufuzzy": "1.0.18", diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/mapper.ts b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/mapper.ts index 1778ea89bfc..0e3e0c1a8bf 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/mapper.ts +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/mapper.ts @@ -1,16 +1,40 @@ import { TimeOption, TimeRange, TimeZone, rangeUtil, dateTimeFormat } from '@grafana/data'; +import { formatDateRange } from '@grafana/i18n'; +import { getFeatureToggle } from '../../../utils/featureToggle'; export const mapOptionToTimeRange = (option: TimeOption, timeZone?: TimeZone): TimeRange => { return rangeUtil.convertRawToRange({ from: option.from, to: option.to }, timeZone); }; +const rangeFormatShort: Intl.DateTimeFormatOptions = { + dateStyle: 'short', + timeStyle: 'short', +}; + +const rangeFormatFull: Intl.DateTimeFormatOptions = { + dateStyle: 'short', + timeStyle: 'medium', +}; + export const mapRangeToTimeOption = (range: TimeRange, timeZone?: TimeZone): TimeOption => { const from = dateTimeFormat(range.from, { timeZone }); const to = dateTimeFormat(range.to, { timeZone }); + let display = `${from} to ${to}`; + + if (getFeatureToggle('localeFormatPreference')) { + const fromDate = range.from.toDate(); + const toDate = range.to.toDate(); + + // The short time format doesn't include seconds, so change the format + // if the range includes seconds + const hasSeconds = fromDate.getSeconds() !== 0 || toDate.getSeconds() !== 0; + display = formatDateRange(fromDate, toDate, hasSeconds ? rangeFormatFull : rangeFormatShort); + } + return { from, to, - display: `${from} to ${to}`, + display, }; }; diff --git a/pkg/expr/sql/dummy_arm.go b/pkg/expr/sql/dummy_arm.go index 89c0d701545..21cd358a725 100644 --- a/pkg/expr/sql/dummy_arm.go +++ b/pkg/expr/sql/dummy_arm.go @@ -15,7 +15,7 @@ type DB struct{} // Stub out the QueryFrames method for ARM builds // See github.com/dolthub/go-mysql-server/issues/2837 -func (db *DB) QueryFrames(_ context.Context, _ tracing.Tracer, _, _ string, _ []*data.Frame, _...QueryOption) (*data.Frame, error) { +func (db *DB) QueryFrames(_ context.Context, _ tracing.Tracer, _, _ string, _ []*data.Frame, _ ...QueryOption) (*data.Frame, error) { return nil, fmt.Errorf("sql expressions not supported in arm") } diff --git a/public/app/app.ts b/public/app/app.ts index 4fbfe064bf8..4e19b6b3fa0 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -1,6 +1,7 @@ import 'symbol-observable'; import 'regenerator-runtime/runtime'; +import '@formatjs/intl-durationformat/polyfill'; import 'whatwg-fetch'; // fetch polyfill needed for PhantomJs rendering import 'file-saver'; import 'jquery'; @@ -18,7 +19,6 @@ import { standardFieldConfigEditorRegistry, standardTransformersRegistry, } from '@grafana/data'; -import { initTranslations } from '@grafana/i18n/internal'; import { locationService, registerEchoBackend, @@ -51,6 +51,7 @@ import { import config, { updateConfig } from 'app/core/config'; import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; +import { initializeI18n } from '../../packages/grafana-i18n/src/i18n'; import getDefaultMonacoLanguages from '../lib/monaco-languages'; import { AppWrapper } from './AppWrapper'; @@ -125,15 +126,22 @@ export class GrafanaApp { await preInitTasks(); // Let iframe container know grafana has started loading window.parent.postMessage('GrafanaAppInit', '*'); + const regionalFormat = config.featureToggles.localeFormatPreference + ? config.locale + : config.bootData.user.language; + + const initI18nPromise = initializeI18n( + { + language: config.bootData.user.language, + ns: NAMESPACES, + module: loadTranslations, + }, + regionalFormat + ); // This is a placeholder so we can put a 'comment' in the message json files. // Starts with an underscore so it's sorted to the top of the file. Even though it is in a comment the following line is still extracted // t('_comment', 'The code is the source of truth for English phrases. They should be updated in the components directly, and additional plurals specified in this file.'); - const initI18nPromise = initTranslations({ - language: config.bootData.user.language, - ns: NAMESPACES, - module: loadTranslations, - }); initI18nPromise.then(({ language }) => updateConfig({ language })); setBackendSrv(backendSrv); diff --git a/public/app/core/internationalization/dates.ts b/public/app/core/internationalization/dates.ts index f60f9a3604c..609002dbbb0 100644 --- a/public/app/core/internationalization/dates.ts +++ b/public/app/core/internationalization/dates.ts @@ -1,4 +1,3 @@ -import '@formatjs/intl-durationformat/polyfill'; import deepEqual from 'fast-deep-equal'; import memoize from 'micro-memoize'; diff --git a/public/app/types/intl.d.ts b/public/app/types/intl.d.ts new file mode 100644 index 00000000000..7a4ac64930c --- /dev/null +++ b/public/app/types/intl.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/yarn.lock b/yarn.lock index 6c6f12cee3a..0f9565dd714 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3175,11 +3175,14 @@ __metadata: version: 0.0.0-use.local resolution: "@grafana/i18n@workspace:packages/grafana-i18n" dependencies: + "@formatjs/intl-durationformat": "npm:^0.7.0" "@grafana/tsconfig": "npm:^2.0.0" "@types/react": "npm:18.3.18" + fast-deep-equal: "npm:^3.1.3" i18next: "npm:^24.0.0" i18next-browser-languagedetector: "npm:^8.0.0" i18next-pseudo: "npm:^2.2.1" + micro-memoize: "npm:^4.1.2" react-i18next: "npm:^15.0.0" rollup: "npm:^4.22.4" typescript: "npm:5.7.3" @@ -3608,6 +3611,7 @@ __metadata: "@grafana/data": "npm:12.1.0-pre" "@grafana/e2e-selectors": "npm:12.1.0-pre" "@grafana/faro-web-sdk": "npm:^1.13.2" + "@grafana/i18n": "npm:12.1.0-pre" "@grafana/schema": "npm:12.1.0-pre" "@grafana/tsconfig": "npm:^2.0.0" "@hello-pangea/dnd": "npm:17.0.0"