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:
+144
-6
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+5
-4
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user