diff --git a/packages/grafana-data/src/datetime/formatter.test.ts b/packages/grafana-data/src/datetime/formatter.test.ts index 18bea1cde78..7ec7da8adc5 100644 --- a/packages/grafana-data/src/datetime/formatter.test.ts +++ b/packages/grafana-data/src/datetime/formatter.test.ts @@ -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'); }); }); diff --git a/packages/grafana-data/src/datetime/formatter.ts b/packages/grafana-data/src/datetime/formatter.ts index 7facb730195..cc90e27491a 100644 --- a/packages/grafana-data/src/datetime/formatter.ts +++ b/packages/grafana-data/src/datetime/formatter.ts @@ -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 = (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 = (dateInUtc, options?) => - toTz(dateInUtc, getTimeZone(options)).format(getFormat(options)); +export const dateTimeFormat: 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(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 diff --git a/packages/grafana-data/src/datetime/rangeutil.test.ts b/packages/grafana-data/src/datetime/rangeutil.test.ts index 684e4a11935..81ac6e2f6f8 100644 --- a/packages/grafana-data/src/datetime/rangeutil.test.ts +++ b/packages/grafana-data/src/datetime/rangeutil.test.ts @@ -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'); + }); + }); }); diff --git a/packages/grafana-data/src/datetime/rangeutil.ts b/packages/grafana-data/src/datetime/rangeutil.ts index 2a93c282551..680ce22dcf0 100644 --- a/packages/grafana-data/src/datetime/rangeutil.ts +++ b/packages/grafana-data/src/datetime/rangeutil.ts @@ -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) : ''; diff --git a/packages/grafana-data/src/utils/featureToggles.ts b/packages/grafana-data/src/utils/featureToggles.ts new file mode 100644 index 00000000000..125c8fa8536 --- /dev/null +++ b/packages/grafana-data/src/utils/featureToggles.ts @@ -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; +} diff --git a/packages/grafana-i18n/src/dates.ts b/packages/grafana-i18n/src/dates.ts index db699e871ae..c75cd2c86f4 100644 --- a/packages/grafana-i18n/src/dates.ts +++ b/packages/grafana-i18n/src/dates.ts @@ -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) { + 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; }; diff --git a/packages/grafana-i18n/src/index.ts b/packages/grafana-i18n/src/index.ts index a4ebd2b5289..8a83bb17c1b 100644 --- a/packages/grafana-i18n/src/index.ts +++ b/packages/grafana-i18n/src/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/mapper.ts b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/mapper.ts index cce0bb76e37..160a1bdbc96 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/mapper.ts +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/mapper.ts @@ -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',