mirror of
https://github.com/grafana/grafana.git
synced 2025-07-25 16:33:51 +08:00
I18n: Use locale-aware date formatting in grafana-data (#108415)
* use intl locale-aware date formatting * format range for time picker button * comment * fix test setup
This commit is contained in:
@ -1,5 +1,90 @@
|
||||
import { initRegionalFormatForTests } from '@grafana/i18n';
|
||||
|
||||
import * as featureToggles from '../utils/featureToggles';
|
||||
|
||||
import { dateTimeFormat, dateTimeFormatTimeAgo, dateTimeFormatWithAbbrevation, timeZoneAbbrevation } from './formatter';
|
||||
|
||||
// Default time zone ("browser") is set to Pacific/Easter in jest.config.js
|
||||
const referenceDate = '2020-04-17T12:36:15.779Z';
|
||||
|
||||
describe('dateTimeFormat (regionalFormatPreference)', () => {
|
||||
let mockGetFeatureToggle: jest.SpyInstance;
|
||||
|
||||
beforeAll(() => {
|
||||
initRegionalFormatForTests('en-AU');
|
||||
mockGetFeatureToggle = jest.spyOn(featureToggles, 'getFeatureToggle').mockImplementation((featureName) => {
|
||||
return featureName === 'localeFormatPreference';
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mockGetFeatureToggle.mockRestore();
|
||||
});
|
||||
|
||||
it('formats dates in the browsers timezone by default', () => {
|
||||
expect(dateTimeFormat(referenceDate)).toBe('17/04/2020, 6:36:15 am');
|
||||
});
|
||||
|
||||
it('formats dates in the browsers timezone by default when invalid time zone is set', () => {
|
||||
const options = { timeZone: 'asdf123' };
|
||||
expect(dateTimeFormat(referenceDate, options)).toBe('17/04/2020, 6:36:15 am');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['UTC', '17/04/2020, 12:36:15 pm'],
|
||||
['Europe/Stockholm', '17/04/2020, 2:36:15 pm'],
|
||||
['Australia/Perth', '17/04/2020, 8:36:15 pm'],
|
||||
['Asia/Yakutsk', '17/04/2020, 9:36:15 pm'],
|
||||
['America/Panama', '17/04/2020, 7:36:15 am'],
|
||||
['America/Los_Angeles', '17/04/2020, 5:36:15 am'],
|
||||
['Africa/Djibouti', '17/04/2020, 3:36:15 pm'],
|
||||
['Europe/London', '17/04/2020, 1:36:15 pm'],
|
||||
['Europe/Berlin', '17/04/2020, 2:36:15 pm'],
|
||||
['Europe/Moscow', '17/04/2020, 3:36:15 pm'],
|
||||
['Europe/Madrid', '17/04/2020, 2:36:15 pm'],
|
||||
['America/New_York', '17/04/2020, 8:36:15 am'],
|
||||
['America/Chicago', '17/04/2020, 7:36:15 am'],
|
||||
['America/Denver', '17/04/2020, 6:36:15 am'],
|
||||
])('formats with the supplied time zone %s', (timeZone, expected) => {
|
||||
expect(dateTimeFormat(referenceDate, { timeZone })).toBe(expected);
|
||||
});
|
||||
|
||||
it('does not include seconds in the output if the time does not', () => {
|
||||
expect(dateTimeFormat('2020-04-17T12:36:00.000Z')).toBe('17/04/2020, 6:36 am');
|
||||
});
|
||||
|
||||
it("includes milliseconds in the output when 'defaultWithMS' is set to true", () => {
|
||||
expect(dateTimeFormat('2020-04-17T12:36:15.123Z', { defaultWithMS: true })).toBe('17/04/2020, 6:36:15.123 am');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['en-GB', '17/04/2020, 06:36:15'],
|
||||
['en-US', '4/17/2020, 6:36:15 AM'],
|
||||
['fr-FR', '17/04/2020 06:36:15'],
|
||||
['es-ES', '17/4/2020, 6:36:15'],
|
||||
['de-DE', '17.4.2020, 06:36:15'],
|
||||
['pt-BR', '17/04/2020, 06:36:15'],
|
||||
['zh-Hans', '2020/4/17 06:36:15'],
|
||||
['it-IT', '17/04/2020, 06:36:15'],
|
||||
['ja-JP', '2020/4/17 6:36:15'],
|
||||
['id-ID', '17/4/2020, 06.36.15'],
|
||||
['ko-KR', '2020. 4. 17. 오전 6:36:15'],
|
||||
['ru-RU', '17.04.2020, 06:36:15'],
|
||||
['cs-CZ', '17. 4. 2020 6:36:15'],
|
||||
['nl-NL', '17-4-2020, 06:36:15'],
|
||||
['hu-HU', '2020. 04. 17. 6:36:15'],
|
||||
['pt-PT', '17/04/2020, 06:36:15'],
|
||||
['pl-PL', '17.04.2020, 06:36:15'],
|
||||
['sv-SE', '2020-04-17 06:36:15'],
|
||||
['tr-TR', '17.04.2020 06:36:15'],
|
||||
['zh-Hant', '2020/4/17 上午6:36:15'],
|
||||
])('with locale %s', (locale, expected) => {
|
||||
initRegionalFormatForTests(locale);
|
||||
|
||||
expect(dateTimeFormat(referenceDate)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dateTimeFormat', () => {
|
||||
describe('when no time zone have been set', () => {
|
||||
const browserTime = dateTimeFormat(1587126975779, { timeZone: 'browser' });
|
||||
@ -81,7 +166,7 @@ describe('dateTimeFormat', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DateTimeFormatISO', () => {
|
||||
describe('with custom ISO format', () => {
|
||||
it('should format according to ISO standard', () => {
|
||||
const options = { timeZone: 'Europe/Stockholm', format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' };
|
||||
expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17T14:36:15.779+02:00');
|
||||
@ -95,39 +180,41 @@ describe('dateTimeFormat', () => {
|
||||
expect(dateTimeFormat(1587126975779, options)).toBe('2020-04-17T14:36:15.779+02:00');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('dateTimeFormatTimeAgo', () => {
|
||||
it('should return the correct format for years ago', () => {
|
||||
const options = { timeZone: 'Europe/Stockholm' };
|
||||
expect(dateTimeFormatTimeAgo(1587126975779, options)).toContain('years ago');
|
||||
});
|
||||
});
|
||||
describe('dateTimeFormatWithAbbreviation', () => {
|
||||
it('should return the correct format with zone abbreviation', () => {
|
||||
const options = { timeZone: 'Europe/Stockholm' };
|
||||
expect(dateTimeFormatWithAbbrevation(1587126975779, options)).toBe('2020-04-17 14:36:15 CEST');
|
||||
});
|
||||
it('should return the correct format with zone abbreviation', () => {
|
||||
const options = { timeZone: 'America/New_York' };
|
||||
expect(dateTimeFormatWithAbbrevation(1587126975779, options)).toBe('2020-04-17 08:36:15 EDT');
|
||||
});
|
||||
it('should return the correct format with zone abbreviation', () => {
|
||||
const options = { timeZone: 'Europe/Bucharest' };
|
||||
expect(dateTimeFormatWithAbbrevation(1587126975779, options)).toBe('2020-04-17 15:36:15 EEST');
|
||||
});
|
||||
});
|
||||
describe('timeZoneAbbrevation', () => {
|
||||
it('should return the correct abbreviation', () => {
|
||||
const options = { timeZone: 'Europe/Stockholm' };
|
||||
expect(timeZoneAbbrevation(1587126975779, options)).toBe('CEST');
|
||||
});
|
||||
it('should return the correct abbreviation', () => {
|
||||
const options = { timeZone: 'America/New_York' };
|
||||
expect(timeZoneAbbrevation(1587126975779, options)).toBe('EDT');
|
||||
});
|
||||
it('should return the correct abbreviation', () => {
|
||||
const options = { timeZone: 'Europe/Bucharest' };
|
||||
expect(timeZoneAbbrevation(1587126975779, options)).toBe('EEST');
|
||||
});
|
||||
describe('dateTimeFormatTimeAgo', () => {
|
||||
it('should return the correct format for years ago', () => {
|
||||
const options = { timeZone: 'Europe/Stockholm' };
|
||||
expect(dateTimeFormatTimeAgo(1587126975779, options)).toContain('years ago');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dateTimeFormatWithAbbreviation', () => {
|
||||
it('should return the correct format with zone abbreviation', () => {
|
||||
const options = { timeZone: 'Europe/Stockholm' };
|
||||
expect(dateTimeFormatWithAbbrevation(1587126975779, options)).toBe('2020-04-17 14:36:15 CEST');
|
||||
});
|
||||
it('should return the correct format with zone abbreviation', () => {
|
||||
const options = { timeZone: 'America/New_York' };
|
||||
expect(dateTimeFormatWithAbbrevation(1587126975779, options)).toBe('2020-04-17 08:36:15 EDT');
|
||||
});
|
||||
it('should return the correct format with zone abbreviation', () => {
|
||||
const options = { timeZone: 'Europe/Bucharest' };
|
||||
expect(dateTimeFormatWithAbbrevation(1587126975779, options)).toBe('2020-04-17 15:36:15 EEST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeZoneAbbrevation', () => {
|
||||
it('should return the correct abbreviation', () => {
|
||||
const options = { timeZone: 'Europe/Stockholm' };
|
||||
expect(timeZoneAbbrevation(1587126975779, options)).toBe('CEST');
|
||||
});
|
||||
it('should return the correct abbreviation', () => {
|
||||
const options = { timeZone: 'America/New_York' };
|
||||
expect(timeZoneAbbrevation(1587126975779, options)).toBe('EDT');
|
||||
});
|
||||
it('should return the correct abbreviation', () => {
|
||||
const options = { timeZone: 'Europe/Bucharest' };
|
||||
expect(timeZoneAbbrevation(1587126975779, options)).toBe('EEST');
|
||||
});
|
||||
});
|
||||
|
@ -1,12 +1,77 @@
|
||||
/* eslint-disable id-blacklist, no-restricted-imports */
|
||||
import moment, { Moment } from 'moment-timezone';
|
||||
|
||||
import { formatDate } from '@grafana/i18n';
|
||||
|
||||
import { TimeZone } from '../types/time';
|
||||
import { getFeatureToggle } from '../utils/featureToggles';
|
||||
|
||||
import { DateTimeOptions, getTimeZone } from './common';
|
||||
import { systemDateFormats } from './formats';
|
||||
import { DateTimeInput, toUtc, dateTimeAsMoment } from './moment_wrapper';
|
||||
|
||||
/**
|
||||
* Converts a Grafana DateTimeInput to a plain Javascript Date object.
|
||||
*/
|
||||
function toDate(dateInUtc: DateTimeInput): Date {
|
||||
if (dateInUtc instanceof Date) {
|
||||
return dateInUtc;
|
||||
}
|
||||
|
||||
if (typeof dateInUtc === 'string' || typeof dateInUtc === 'number') {
|
||||
return new Date(dateInUtc);
|
||||
}
|
||||
|
||||
return dateTimeAsMoment(dateInUtc).toDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Grafana timezone string to an IANA timezone string.
|
||||
*/
|
||||
export function toIANATimezone(grafanaTimezone: string) {
|
||||
// Intl APIs will use the browser's timezone by default (if tz is undefined)
|
||||
if (grafanaTimezone === 'browser') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const zone = moment.tz.zone(grafanaTimezone);
|
||||
if (!zone) {
|
||||
// If the timezone is invalid, we default to the browser's timezone
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return grafanaTimezone;
|
||||
}
|
||||
|
||||
function getIntlOptions(
|
||||
date: Date,
|
||||
options?: DateTimeOptionsWithFormat
|
||||
): Intl.DateTimeFormatOptions & { timeZone?: string } {
|
||||
const timeZone = getTimeZone(options);
|
||||
|
||||
const intlOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric', // ↔ dateStyle: 'short'
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric', // ↔ timeStyle: 'short'
|
||||
minute: 'numeric',
|
||||
timeZone: toIANATimezone(timeZone),
|
||||
};
|
||||
|
||||
// If the time has seconds, ensure they're included in the format
|
||||
const hasSeconds = date.getSeconds() !== 0;
|
||||
if (hasSeconds) {
|
||||
intlOptions.second = 'numeric';
|
||||
}
|
||||
|
||||
if (options?.defaultWithMS) {
|
||||
intlOptions.second = 'numeric';
|
||||
intlOptions.fractionalSecondDigits = 3; // Include milliseconds
|
||||
}
|
||||
|
||||
return intlOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type describing the options that can be passed to the {@link dateTimeFormat}
|
||||
* helper function to control how the date and time value passed to the function is
|
||||
@ -23,17 +88,30 @@ export interface DateTimeOptionsWithFormat extends DateTimeOptions {
|
||||
|
||||
type DateTimeFormatter<T extends DateTimeOptions = DateTimeOptions> = (dateInUtc: DateTimeInput, options?: T) => string;
|
||||
|
||||
// NOTE:
|
||||
// These date formatting functions now just wrap the @grafana/i18n formatting functions
|
||||
// (which themselves wrap the browserIntl APIs). In the future we may deprecate these
|
||||
// in favor of using @grafana/i18n directly.
|
||||
|
||||
/**
|
||||
* Helper function to format date and time according to the specified options. If no options
|
||||
* are supplied, then default values are used. For more details, see {@link DateTimeOptionsWithFormat}.
|
||||
* Helper function to format date and time according to the specified options.
|
||||
* If no options are supplied, then the date is formatting according to the user's locale preference.
|
||||
*
|
||||
* @param dateInUtc - date in UTC format, e.g. string formatted with UTC offset, UNIX epoch in seconds etc.
|
||||
* @param options
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const dateTimeFormat: DateTimeFormatter<DateTimeOptionsWithFormat> = (dateInUtc, options?) =>
|
||||
toTz(dateInUtc, getTimeZone(options)).format(getFormat(options));
|
||||
export const dateTimeFormat: DateTimeFormatter<DateTimeOptionsWithFormat> = (dateInUtc, options?) => {
|
||||
// If a custom format is provided (or the toggle isn't enabled), use the previous implementation
|
||||
if (!getFeatureToggle('localeFormatPreference') || options?.format) {
|
||||
return toTz(dateInUtc, getTimeZone(options)).format(getFormat(options));
|
||||
}
|
||||
|
||||
const dateAsDate = toDate(dateInUtc);
|
||||
const intlOptions = getIntlOptions(dateAsDate, options); // TODO - if invalid timezone, use browser timezone
|
||||
return formatDate(dateAsDate, intlOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to format date and time according to the standard ISO format e.g. 2013-02-04T22:44:30.652Z.
|
||||
@ -70,8 +148,18 @@ export const dateTimeFormatTimeAgo: DateTimeFormatter = (dateInUtc, options?) =>
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const dateTimeFormatWithAbbrevation: DateTimeFormatter = (dateInUtc, options?) =>
|
||||
toTz(dateInUtc, getTimeZone(options)).format(`${systemDateFormats.fullDate} z`);
|
||||
export const dateTimeFormatWithAbbrevation: DateTimeFormatter = (dateInUtc, options?) => {
|
||||
// If a custom format is provided (or the toggle isn't enabled), use the previous implementation
|
||||
if (!getFeatureToggle('localeFormatPreference') || options?.format) {
|
||||
return toTz(dateInUtc, getTimeZone(options)).format(`${systemDateFormats.fullDate} z`);
|
||||
}
|
||||
|
||||
const dateAsDate = toDate(dateInUtc);
|
||||
const intlOptions = getIntlOptions(dateAsDate, options);
|
||||
intlOptions.timeZoneName = 'short';
|
||||
|
||||
return formatDate(dateAsDate, intlOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to return only the time zone abbreviation for a given date and time value. If no options
|
||||
|
@ -1,9 +1,13 @@
|
||||
import { initRegionalFormatForTests } from '@grafana/i18n';
|
||||
|
||||
import { RawTimeRange, TimeRange } from '../types/time';
|
||||
import * as featureToggles from '../utils/featureToggles';
|
||||
|
||||
import { dateTime } from './moment_wrapper';
|
||||
import {
|
||||
convertRawToRange,
|
||||
describeInterval,
|
||||
describeTimeRange,
|
||||
isRelativeTimeRange,
|
||||
relativeToTimeRange,
|
||||
roundInterval,
|
||||
@ -310,4 +314,180 @@ describe('Range Utils', () => {
|
||||
expect(relativeTimeRange.to).toEqual(604800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeTimeRange', () => {
|
||||
it.each([
|
||||
['now-5m', 'now', 'Last 5 minutes'],
|
||||
['now-15m', 'now', 'Last 15 minutes'],
|
||||
['now-1h', 'now', 'Last 1 hour'],
|
||||
['now-24h', 'now', 'Last 24 hours'],
|
||||
['now-7d', 'now', 'Last 7 days'],
|
||||
['now-30d', 'now', 'Last 30 days'],
|
||||
['now/d', 'now/d', 'Today'],
|
||||
['now/d', 'now', 'Today so far'],
|
||||
['now/w', 'now/w', 'This week'],
|
||||
['now/M', 'now/M', 'This month'],
|
||||
['now/y', 'now/y', 'This year'],
|
||||
['now-1d/d', 'now-1d/d', 'Yesterday'],
|
||||
['now-1w/w', 'now-1w/w', 'Previous week'],
|
||||
['now-1M/M', 'now-1M/M', 'Previous month'],
|
||||
['now-1y/y', 'now-1y/y', 'Previous year'],
|
||||
['now/fQ', 'now/fQ', 'This fiscal quarter'],
|
||||
['now/fy', 'now/fy', 'This fiscal year'],
|
||||
['now-1Q/fQ', 'now-1Q/fQ', 'Previous fiscal quarter'],
|
||||
['now-1y/fy', 'now-1y/fy', 'Previous fiscal year'],
|
||||
])('should return display name "%s" for range from "%s" to "%s"', (from, to, expected) => {
|
||||
expect(describeTimeRange({ from, to })).toBe(expected);
|
||||
});
|
||||
|
||||
it('should prioritize custom quick ranges over standard ranges', () => {
|
||||
const customRanges = [{ from: 'now-5m', to: 'now', display: 'Lightning round!' }];
|
||||
|
||||
expect(describeTimeRange({ from: 'now-5m', to: 'now' }, undefined, customRanges)).toBe('Lightning round!');
|
||||
});
|
||||
|
||||
it('should format with the default timezone', () => {
|
||||
// Tests default to Pacific/Easter
|
||||
const from = dateTime('2023-01-15T10:30:00Z');
|
||||
const to = dateTime('2023-01-15T14:45:00Z');
|
||||
|
||||
const result = describeTimeRange({ from, to });
|
||||
expect(result).toBe('2023-01-15 05:30:00 to 2023-01-15 09:45:00');
|
||||
});
|
||||
|
||||
it('should respect timezone in datetime formatting', () => {
|
||||
const from = dateTime('2023-01-15T10:30:00Z');
|
||||
const to = dateTime('2023-01-15T14:45:00Z');
|
||||
|
||||
const result = describeTimeRange({ from, to }, 'Australia/Sydney');
|
||||
expect(result).toBe('2023-01-15 21:30:00 to 2023-01-16 01:45:00');
|
||||
});
|
||||
|
||||
it('should handle absolute from, relative to', () => {
|
||||
const from = dateTime('2023-01-15T10:30:00Z');
|
||||
const to = 'now';
|
||||
|
||||
const result = describeTimeRange({ from, to });
|
||||
expect(result).toBe('2023-01-15 05:30:00 to a few seconds ago');
|
||||
});
|
||||
|
||||
it('should handle relative from, absolute to', () => {
|
||||
const from = 'now-1h';
|
||||
const to = dateTime('2023-01-15T14:45:00Z');
|
||||
|
||||
const result = describeTimeRange({ from, to });
|
||||
expect(result).toBe('an hour ago to 2023-01-15 09:45:00');
|
||||
});
|
||||
|
||||
it('should handle invalid relative expressions', () => {
|
||||
const from = dateTime('2023-01-15T10:30:00Z');
|
||||
const to = 'invalid-expression';
|
||||
|
||||
const result = describeTimeRange({ from, to });
|
||||
expect(result).toContain('Invalid date');
|
||||
});
|
||||
|
||||
it('should handle empty custom ranges array', () => {
|
||||
expect(describeTimeRange({ from: 'now-5m', to: 'now' }, undefined, [])).toBe('Last 5 minutes');
|
||||
});
|
||||
|
||||
it('should handle null custom ranges', () => {
|
||||
expect(describeTimeRange({ from: 'now-5m', to: 'now' }, undefined, undefined)).toBe('Last 5 minutes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeTimeRange - localeFormatPreference enabled', () => {
|
||||
let mockGetFeatureToggle: jest.SpyInstance;
|
||||
|
||||
beforeAll(() => {
|
||||
initRegionalFormatForTests('en-AU');
|
||||
mockGetFeatureToggle = jest.spyOn(featureToggles, 'getFeatureToggle').mockImplementation((featureName) => {
|
||||
return featureName === 'localeFormatPreference';
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mockGetFeatureToggle.mockRestore();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['now-5m', 'now', 'Last 5 minutes'],
|
||||
['now-15m', 'now', 'Last 15 minutes'],
|
||||
['now-1h', 'now', 'Last 1 hour'],
|
||||
['now-7d', 'now', 'Last 7 days'],
|
||||
['now/d', 'now/d', 'Today'],
|
||||
['now/d', 'now', 'Today so far'],
|
||||
['now/w', 'now/w', 'This week'],
|
||||
['now-1d/d', 'now-1d/d', 'Yesterday'],
|
||||
['now-1w/w', 'now-1w/w', 'Previous week'],
|
||||
['now-1y/y', 'now-1y/y', 'Previous year'],
|
||||
['now/fQ', 'now/fQ', 'This fiscal quarter'],
|
||||
['now-1Q/fQ', 'now-1Q/fQ', 'Previous fiscal quarter'],
|
||||
])('should return display name "%s" for range from "%s" to "%s"', (from, to, expected) => {
|
||||
expect(describeTimeRange({ from, to })).toBe(expected);
|
||||
});
|
||||
|
||||
it('should prioritize custom quick ranges over standard ranges', () => {
|
||||
const customRanges = [{ from: 'now-5m', to: 'now', display: 'Lightning round!' }];
|
||||
|
||||
expect(describeTimeRange({ from: 'now-5m', to: 'now' }, undefined, customRanges)).toBe('Lightning round!');
|
||||
});
|
||||
|
||||
it('should format with the default timezone', () => {
|
||||
// Tests default to Pacific/Easter
|
||||
const from = dateTime('2023-01-15T10:30:00Z');
|
||||
const to = dateTime('2023-01-15T14:45:00Z');
|
||||
|
||||
const result = describeTimeRange({ from, to });
|
||||
expect(result).toBe('15/1/23, 5:30 – 9:45 am');
|
||||
});
|
||||
|
||||
it('should respect timezone in datetime formatting', () => {
|
||||
const from = dateTime('2023-01-15T10:30:00Z');
|
||||
const to = dateTime('2023-01-15T14:45:00Z');
|
||||
|
||||
const result = describeTimeRange({ from, to }, 'Australia/Sydney');
|
||||
expect(result).toBe('15/1/23, 9:30 pm – 16/1/23, 1:45 am');
|
||||
});
|
||||
|
||||
it('should include seconds in the output if the time has seconds', () => {
|
||||
const from = dateTime('2023-01-15T10:30:00Z');
|
||||
const to = dateTime('2023-01-15T14:45:12Z');
|
||||
|
||||
const result = describeTimeRange({ from, to });
|
||||
expect(result).toBe('15/1/23, 5:30:00 am – 9:45:12 am');
|
||||
});
|
||||
|
||||
it('should handle absolute from, relative to', () => {
|
||||
const from = dateTime('2023-01-15T10:30:00Z');
|
||||
const to = 'now';
|
||||
|
||||
const result = describeTimeRange({ from, to });
|
||||
expect(result).toBe('15/01/2023, 5:30 am to a few seconds ago');
|
||||
});
|
||||
|
||||
it('should handle relative from, absolute to', () => {
|
||||
const from = 'now-1h';
|
||||
const to = dateTime('2023-01-15T14:45:00Z');
|
||||
|
||||
const result = describeTimeRange({ from, to });
|
||||
expect(result).toBe('an hour ago to 15/01/2023, 9:45 am');
|
||||
});
|
||||
|
||||
it('should handle invalid relative expressions', () => {
|
||||
const from = dateTime('2023-01-15T10:30:00Z');
|
||||
const to = 'invalid-expression';
|
||||
|
||||
const result = describeTimeRange({ from, to });
|
||||
expect(result).toContain('Invalid date');
|
||||
});
|
||||
|
||||
it('should handle empty custom ranges array', () => {
|
||||
expect(describeTimeRange({ from: 'now-5m', to: 'now' }, undefined, [])).toBe('Last 5 minutes');
|
||||
});
|
||||
|
||||
it('should handle null custom ranges', () => {
|
||||
expect(describeTimeRange({ from: 'now-5m', to: 'now' }, undefined, undefined)).toBe('Last 5 minutes');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { formatDateRange } from '@grafana/i18n';
|
||||
|
||||
import { RawTimeRange, TimeRange, TimeZone, IntervalValues, RelativeTimeRange, TimeOption } from '../types/time';
|
||||
import { getFeatureToggle } from '../utils/featureToggles';
|
||||
|
||||
import * as dateMath from './datemath';
|
||||
import { timeZoneAbbrevation, dateTimeFormat, dateTimeFormatTimeAgo } from './formatter';
|
||||
import { timeZoneAbbrevation, dateTimeFormat, dateTimeFormatTimeAgo, toIANATimezone } from './formatter';
|
||||
import { isDateTime, DateTime, dateTime } from './moment_wrapper';
|
||||
import { dateTimeParse } from './parser';
|
||||
|
||||
@ -134,6 +137,17 @@ export function describeTextRange(expr: string): TimeOption {
|
||||
return opt;
|
||||
}
|
||||
|
||||
// TODO: Should we keep these format presets somewhere common?
|
||||
const rangeFormatShort: Intl.DateTimeFormatOptions = {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
};
|
||||
|
||||
const rangeFormatFull: Intl.DateTimeFormatOptions = {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this function to get a properly formatted string representation of a {@link @grafana/data:RawTimeRange | range}.
|
||||
*
|
||||
@ -154,9 +168,25 @@ export function describeTimeRange(range: RawTimeRange, timeZone?: TimeZone, quic
|
||||
const options = { timeZone };
|
||||
|
||||
if (isDateTime(range.from) && isDateTime(range.to)) {
|
||||
return dateTimeFormat(range.from, options) + ' to ' + dateTimeFormat(range.to, options);
|
||||
const fromDate = range.from.toDate();
|
||||
const toDate = range.to.toDate();
|
||||
|
||||
if (!getFeatureToggle('localeFormatPreference')) {
|
||||
return dateTimeFormat(range.from, options) + ' to ' + dateTimeFormat(range.to, options);
|
||||
}
|
||||
|
||||
const hasSeconds = fromDate.getSeconds() !== 0 || toDate.getSeconds() !== 0;
|
||||
const intlFormat = hasSeconds ? rangeFormatFull : rangeFormatShort;
|
||||
const intlFormatOptions = {
|
||||
...intlFormat,
|
||||
timeZone: timeZone ? toIANATimezone(timeZone) : undefined,
|
||||
};
|
||||
|
||||
return formatDateRange(fromDate, toDate, intlFormatOptions);
|
||||
}
|
||||
|
||||
// TODO: We could update these to all use Intl APIs.
|
||||
// Could we use formatRangeToParts and replace the 'other side' with the ago formatting?
|
||||
if (isDateTime(range.from)) {
|
||||
const parsed = dateMath.parse(range.to, true, 'utc');
|
||||
return parsed ? dateTimeFormat(range.from, options) + ' to ' + dateTimeFormatTimeAgo(parsed, options) : '';
|
||||
|
13
packages/grafana-data/src/utils/featureToggles.ts
Normal file
13
packages/grafana-data/src/utils/featureToggles.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { FeatureToggles } from '../types/featureToggles.gen';
|
||||
|
||||
type FeatureToggleName = keyof FeatureToggles;
|
||||
|
||||
/**
|
||||
* Check a featureToggle
|
||||
* @param featureName featureToggle name
|
||||
* @param def default value if featureToggles aren't defined, false if not provided
|
||||
* @returns featureToggle value or def.
|
||||
*/
|
||||
export function getFeatureToggle(featureName: FeatureToggleName, def = false) {
|
||||
return window.grafanaBootData?.settings.featureToggles[featureName] ?? def;
|
||||
}
|
@ -1,8 +1,13 @@
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import memoize from 'micro-memoize';
|
||||
import memoize, { AnyFn, Memoized } from 'micro-memoize';
|
||||
|
||||
const deepMemoize: typeof memoize = (fn) => memoize(fn, { isEqual: deepEqual });
|
||||
|
||||
function clearMemoizedCache(fn: Memoized<AnyFn>) {
|
||||
fn.cache.keys.length = 0;
|
||||
fn.cache.values.length = 0;
|
||||
}
|
||||
|
||||
let regionalFormat: string | undefined;
|
||||
|
||||
const createDateTimeFormatter = deepMemoize((locale: string | undefined, options: Intl.DateTimeFormatOptions) => {
|
||||
@ -41,5 +46,10 @@ export const formatDateRange = (
|
||||
};
|
||||
|
||||
export const initRegionalFormat = (regionalFormatArg: string) => {
|
||||
// We don't expect this to be called with a different locale during the lifetime of the app,
|
||||
// so this is mostly here so we can change it during tests and clear out previously memoized values.
|
||||
clearMemoizedCache(formatDate);
|
||||
clearMemoizedCache(formatDuration);
|
||||
|
||||
regionalFormat = regionalFormatArg;
|
||||
};
|
||||
|
@ -24,4 +24,4 @@ export {
|
||||
} from './constants';
|
||||
export { initPluginTranslations, t, Trans } from './i18n';
|
||||
export type { ResourceLoader, Resources, TFunction, TransProps } from './types';
|
||||
export { formatDate, formatDuration, formatDateRange } from './dates';
|
||||
export { formatDate, formatDuration, formatDateRange, initRegionalFormat as initRegionalFormatForTests } from './dates';
|
||||
|
@ -7,6 +7,7 @@ export const mapOptionToTimeRange = (option: TimeOption, timeZone?: TimeZone): T
|
||||
return rangeUtil.convertRawToRange({ from: option.from, to: option.to }, timeZone, undefined, commonFormat);
|
||||
};
|
||||
|
||||
// TODO: Should we keep these format presets somewhere common?
|
||||
const rangeFormatShort: Intl.DateTimeFormatOptions = {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
|
Reference in New Issue
Block a user