TimeRangePicker: Use consistent format in date-time input fields (#107117)

* when localeFormatPreference toggle is enabled, always use a common datetime format in TimeRangePicker

* fix lint issue
improve comment

* test printing datetime input format

* adds tests for entering dates in inputs to be parsed
adds will-be redundant tests to ensure system date format is or isn't respected when appropriate
This commit is contained in:
Luminessa Starlight
2025-07-15 09:07:33 -04:00
committed by GitHub
parent 24ebb27ebe
commit 8480862854
5 changed files with 163 additions and 14 deletions
@@ -1,11 +1,32 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { dateTimeParse, systemDateFormats, TimeRange } from '@grafana/data';
import { dateTimeParse, FeatureToggles, systemDateFormats, TimeRange } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import * as commonFormatModule from '../commonFormat';
import { TimeRangeContent } from './TimeRangeContent';
// If this flag is deleted, this mock also should be, and the additional tests for when
// the flag was disabled.
type LocaleFormatPreferenceType = FeatureToggles['localeFormatPreference'];
jest.mock('../commonFormat', () => {
const format = 'YYYY-MM-DD HH:mm:ss' as const;
const moduleObject = {
__esModule: true,
commonFormat: format as undefined | 'YYYY-MM-DD HH:mm:ss',
mockSetCommonFormat,
};
function mockSetCommonFormat(enabled: LocaleFormatPreferenceType = true) {
moduleObject.commonFormat = enabled ? format : undefined;
}
return moduleObject;
});
// @ts-expect-error mockSetCommonFormat doesn't exist on the export type of commonFormat,
// but it's added above in the mock.
const mockSetCommonFormat: (enabled: LocaleFormatPreferenceType) => void = commonFormatModule.mockSetCommonFormat;
const mockClipboard = {
writeText: jest.fn(),
readText: jest.fn(),
@@ -25,9 +46,16 @@ const customRawTimeRange = {
to: '2023-06-19 23:59:00',
};
const mockOnApply = jest.fn();
beforeEach(() => {
mockSetCommonFormat(true);
mockOnApply.mockClear();
});
function setup(initial: TimeRange = defaultTimeRange, timeZone = 'utc') {
return {
...render(<TimeRangeContent isFullscreen={true} value={initial} onApply={() => {}} timeZone={timeZone} />),
...render(<TimeRangeContent isFullscreen={true} value={initial} onApply={mockOnApply} timeZone={timeZone} />),
getCalendarDayByLabelText: (label: string) => {
const item = screen.getByLabelText(label);
return item?.parentElement as HTMLButtonElement;
@@ -54,7 +82,6 @@ describe('TimeRangeForm', () => {
});
it('should display calendar when clicking the calendar icon', async () => {
const user = userEvent.setup();
setup();
const { TimePicker } = selectors.components;
const openCalendarButton = screen.getAllByRole('button', { name: 'Open calendar' });
@@ -90,6 +117,29 @@ describe('TimeRangeForm', () => {
expect(getByLabelText('To')).toHaveValue('2021-06-19 19:59:00');
});
describe('when common format are entered', () => {
it('parses those dates in the current timezone', async () => {
setup();
const fromInput = screen.getByLabelText('From');
const toInput = screen.getByLabelText('To');
await user.clear(fromInput);
await user.type(fromInput, '2021-05-10 20:00:00');
await user.clear(toInput);
await user.type(toInput, '2021-05-12 19:59:00');
await user.click(screen.getByRole('button', { name: 'Apply time range' }));
const appliedOrUndefined = mockOnApply.mock.lastCall?.at(0) as undefined | TimeRange;
expect(appliedOrUndefined).not.toBe(undefined);
const applied = appliedOrUndefined!; // previous line throws if undefined
expect(applied.from.toISOString()).toBe('2021-05-10T20:00:00.000Z');
expect(applied.to.toISOString()).toBe('2021-05-12T19:59:00.000Z');
});
});
// once localeFormatPreference is permanently on, the only tests that should remain
// in this block will be ones that ensures the system format is *not* used
describe('Given custom system date format', () => {
const originalFullDate = systemDateFormats.fullDate;
beforeEach(() => {
@@ -100,7 +150,7 @@ describe('TimeRangeForm', () => {
systemDateFormats.fullDate = originalFullDate;
});
it('should parse UTC iso strings and render in current timezone', () => {
it('should parse UTC iso strings and render them in the common format and current timezone', () => {
const { getByLabelText } = setup(
{
from: defaultTimeRange.from,
@@ -113,8 +163,96 @@ describe('TimeRangeForm', () => {
'America/New_York'
);
expect(getByLabelText('From')).toHaveValue('16.06.2021 20:00:00');
expect(getByLabelText('To')).toHaveValue('19.06.2021 19:59:00');
expect(getByLabelText('From')).toHaveValue('2021-06-16 20:00:00');
expect(getByLabelText('To')).toHaveValue('2021-06-19 19:59:00');
});
describe('when common format dates are entered', () => {
it('parses those dates in the current timezone', async () => {
setup();
const fromInput = screen.getByLabelText('From');
const toInput = screen.getByLabelText('To');
await user.clear(fromInput);
await user.type(fromInput, '2021-05-10 20:00:00');
await user.clear(toInput);
await user.type(toInput, '2021-05-12 19:59:00');
await user.click(screen.getByRole('button', { name: 'Apply time range' }));
const appliedOrUndefined = mockOnApply.mock.lastCall?.at(0) as undefined | TimeRange;
expect(appliedOrUndefined).not.toBe(undefined);
const applied = appliedOrUndefined!; // previous line throws if undefined
expect(applied.from.toISOString()).toBe('2021-05-10T20:00:00.000Z');
expect(applied.to.toISOString()).toBe('2021-05-12T19:59:00.000Z');
});
});
describe('when the localeFormatPreference feature toggle is off', () => {
beforeEach(() => {
// when localeFormatPreference is permanently on, the parent describe block ("Given custom systemdate format")
// needs to be cleared out as most of these tests will be redundant.
mockSetCommonFormat(false);
});
it('should parse UTC ISO strings and render them in the system format', () => {
const { getByLabelText } = setup(
{
from: defaultTimeRange.from,
to: defaultTimeRange.to,
raw: {
from: defaultTimeRange.from.toISOString(),
to: defaultTimeRange.to.toISOString(),
},
},
'America/New_York'
);
expect(getByLabelText('From')).toHaveValue('16.06.2021 20:00:00');
expect(getByLabelText('To')).toHaveValue('19.06.2021 19:59:00');
});
describe('when common format dates are entered', () => {
it('should show an error because of parsing failure', async () => {
setup();
const fromInput = screen.getByLabelText('From');
const toInput = screen.getByLabelText('To');
await user.clear(fromInput);
await user.type(fromInput, '2021-05-10 20:00:00');
await user.clear(toInput);
await user.type(toInput, '2021-05-12 19:59:00');
await user.click(screen.getByRole('button', { name: 'Apply time range' }));
const error = screen.getAllByRole('alert');
expect(error).toHaveLength(2);
expect(error[0]).toBeVisible();
expect(error[0]).toHaveTextContent('Please enter a past date or "now"');
});
});
describe('when common format dates are entered', () => {
it('should show an error because of parsing failure', async () => {
setup();
const fromInput = screen.getByLabelText('From');
const toInput = screen.getByLabelText('To');
await user.clear(fromInput);
await user.type(fromInput, '10.05.2021 20:00:00');
await user.clear(toInput);
await user.type(toInput, '12.05.2021 19:59:00');
await user.click(screen.getByRole('button', { name: 'Apply time range' }));
const appliedOrUndefined = mockOnApply.mock.lastCall?.at(0) as undefined | TimeRange;
expect(appliedOrUndefined).not.toBe(undefined);
const applied = appliedOrUndefined!; // previous line throws if undefined
expect(applied.from.toISOString()).toBe('2021-05-10T20:00:00.000Z');
expect(applied.to.toISOString()).toBe('2021-05-12T19:59:00.000Z');
});
});
});
});
@@ -23,6 +23,7 @@ import { Icon } from '../../Icon/Icon';
import { Input } from '../../Input/Input';
import { Tooltip } from '../../Tooltip/Tooltip';
import { WeekStart } from '../WeekStartPicker';
import { commonFormat } from '../commonFormat';
import { isValid } from '../utils';
import TimePickerCalendar from './TimePickerCalendar';
@@ -92,7 +93,7 @@ export const TimeRangeContent = (props: Props) => {
}
const raw: RawTimeRange = { from: from.value, to: to.value };
const timeRange = rangeUtil.convertRawToRange(raw, timeZone, fiscalYearStartMonth);
const timeRange = rangeUtil.convertRawToRange(raw, timeZone, fiscalYearStartMonth, commonFormat);
onApplyFromProps(timeRange);
}, [from.invalid, from.value, onApplyFromProps, timeZone, to.invalid, to.value, fiscalYearStartMonth]);
@@ -237,7 +238,7 @@ export const TimeRangeContent = (props: Props) => {
function isRangeInvalid(from: string, to: string, timezone?: string): boolean {
const raw: RawTimeRange = { from, to };
const timeRange = rangeUtil.convertRawToRange(raw, timezone);
const timeRange = rangeUtil.convertRawToRange(raw, timezone, undefined, commonFormat);
const valid = timeRange.from.isSame(timeRange.to) || timeRange.from.isBefore(timeRange.to);
return !valid;
@@ -267,12 +268,12 @@ function valueToState(
function valueAsString(value: DateTime | string, timeZone?: TimeZone): string {
if (isDateTime(value)) {
return dateTimeFormat(value, { timeZone });
return dateTimeFormat(value, { timeZone, format: commonFormat });
}
if (value.endsWith('Z')) {
const dt = dateTimeParse(value);
return dateTimeFormat(dt, { timeZone });
return dateTimeFormat(dt, { timeZone, format: commonFormat });
}
return value;
@@ -2,8 +2,9 @@ import { TimeOption, TimeRange, TimeZone, rangeUtil, dateTimeFormat } from '@gra
import { formatDateRange } from '@grafana/i18n';
import { getFeatureToggle } from '../../../utils/featureToggle';
import { commonFormat } from '../commonFormat';
export const mapOptionToTimeRange = (option: TimeOption, timeZone?: TimeZone): TimeRange => {
return rangeUtil.convertRawToRange({ from: option.from, to: option.to }, timeZone);
return rangeUtil.convertRawToRange({ from: option.from, to: option.to }, timeZone, undefined, commonFormat);
};
const rangeFormatShort: Intl.DateTimeFormatOptions = {
@@ -17,8 +18,8 @@ const rangeFormatFull: Intl.DateTimeFormatOptions = {
};
export const mapRangeToTimeOption = (range: TimeRange, timeZone?: TimeZone): TimeOption => {
const from = dateTimeFormat(range.from, { timeZone });
const to = dateTimeFormat(range.to, { timeZone });
const from = dateTimeFormat(range.from, { timeZone, format: commonFormat });
const to = dateTimeFormat(range.to, { timeZone, format: commonFormat });
let display = `${from} to ${to}`;
@@ -0,0 +1,6 @@
import { getFeatureToggle } from '../../utils/featureToggle';
// The moment.js format to use for datetime inputs when the regionalFormat option is set.
const COMMON_FORMAT = 'YYYY-MM-DD HH:mm:ss';
export const commonFormat = getFeatureToggle('localeFormatPreference') ? COMMON_FORMAT : undefined;
@@ -1,15 +1,18 @@
import { dateMath, dateTimeParse, isDateTime, TimeRange, TimeZone } from '@grafana/data';
import { commonFormat } from './commonFormat';
export function isValid(value: string, roundUp?: boolean, timeZone?: TimeZone): boolean {
if (isDateTime(value)) {
return value.isValid();
}
// handles `now` math
if (dateMath.isMathString(value)) {
return dateMath.isValid(value);
}
const parsed = dateTimeParse(value, { roundUp, timeZone });
const parsed = dateTimeParse(value, { roundUp, timeZone, format: commonFormat });
return parsed.isValid();
}