DateTimePicker: Alternate timezones now behave correctly (#86750)

* Add failing tests for timezone handling

* Fix `DateTimePicker.tsx` timezone handling

- Resolves `onBlur` issue
- Resolve Calendar and TimeOfDay issues
- Update test to cover different timezone

* Handle `console.warn` in test

* Handle `console.warn` in test #2

* Better handling of invalid date

When parsing date string with `dateTime`, adding a second `formatInput` aids in both parsing the actual string and avoid `console.warn` when `moment` reverts to be using `Date`.

* add more test cases

* Ash/proposed changes (#86854)

* simplify

* only need this change

* formatting

* const > let

* add test to ensure calendar is always showing the matching day

* separate state

* undo story changes

* update util function comments

* fix for selecting date in the calendar

---------

Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
This commit is contained in:
Thomas Wikman 2024-04-29 11:46:44 +02:00 committed by GitHub
parent ccd2bff8b0
commit 7fab894e9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 254 additions and 79 deletions

View File

@ -53,7 +53,7 @@ export interface DateTimeDuration {
export interface DateTime extends Object {
add: (amount?: DateTimeInput, unit?: DurationUnit) => DateTime;
set: (unit: DurationUnit, amount: DateTimeInput) => void;
set: (unit: DurationUnit | 'date', amount: DateTimeInput) => void;
diff: (amount: DateTimeInput, unit?: DurationUnit, truncate?: boolean) => number;
endOf: (unitOfTime: DurationUnit) => DateTime;
format: (formatInput?: FormatInput) => string;

View File

@ -2,15 +2,23 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { dateTime } from '@grafana/data';
import { dateTime, dateTimeAsMoment, dateTimeForTimeZone, getTimeZone, setTimeZoneResolver } from '@grafana/data';
import { Components } from '@grafana/e2e-selectors';
import { DateTimePicker, Props } from './DateTimePicker';
// An assortment of timezones that we will test the behavior of the DateTimePicker with different timezones
const TEST_TIMEZONES = ['browser', 'Europe/Stockholm', 'America/Indiana/Marengo'];
const defaultTimeZone = getTimeZone();
afterAll(() => {
return setTimeZoneResolver(() => defaultTimeZone);
});
const renderDatetimePicker = (props?: Props) => {
const combinedProps = Object.assign(
{
date: dateTime('2021-05-05 12:00:00'),
date: dateTimeForTimeZone(getTimeZone(), '2021-05-05 12:00:00'),
onChange: () => {},
},
props
@ -26,12 +34,22 @@ describe('Date time picker', () => {
expect(screen.queryByTestId('date-time-picker')).toBeInTheDocument();
});
it('input should have a value', () => {
it.each(TEST_TIMEZONES)('input should have a value (timezone: %s)', (timeZone) => {
setTimeZoneResolver(() => timeZone);
renderDatetimePicker();
expect(screen.queryByDisplayValue('2021-05-05 12:00:00')).toBeInTheDocument();
const dateTimeInput = screen.getByTestId(Components.DateTimePicker.input);
expect(dateTimeInput).toHaveDisplayValue('2021-05-05 12:00:00');
});
it('should update date onblur', async () => {
it.each(TEST_TIMEZONES)('should render (timezone %s)', (timeZone) => {
setTimeZoneResolver(() => timeZone);
renderDatetimePicker();
const dateTimeInput = screen.getByTestId(Components.DateTimePicker.input);
expect(dateTimeInput).toHaveDisplayValue('2021-05-05 12:00:00');
});
it.each(TEST_TIMEZONES)('should update date onblur (timezone: %)', async (timeZone) => {
setTimeZoneResolver(() => timeZone);
const onChangeInput = jest.fn();
render(<DateTimePicker date={dateTime('2021-05-05 12:00:00')} onChange={onChangeInput} />);
const dateTimeInput = screen.getByTestId(Components.DateTimePicker.input);
@ -42,7 +60,8 @@ describe('Date time picker', () => {
expect(onChangeInput).toHaveBeenCalled();
});
it('should not update onblur if invalid date', async () => {
it.each(TEST_TIMEZONES)('should not update onblur if invalid date (timezone: %s)', async (timeZone) => {
setTimeZoneResolver(() => timeZone);
const onChangeInput = jest.fn();
render(<DateTimePicker date={dateTime('2021-05-05 12:00:00')} onChange={onChangeInput} />);
const dateTimeInput = screen.getByTestId(Components.DateTimePicker.input);
@ -53,31 +72,167 @@ describe('Date time picker', () => {
expect(onChangeInput).not.toHaveBeenCalled();
});
it('should be able to select values in TimeOfDayPicker without blurring the element', async () => {
renderDatetimePicker();
it.each(TEST_TIMEZONES)(
'should not change the day at times near the day boundary (timezone: %s)',
async (timeZone) => {
setTimeZoneResolver(() => timeZone);
const onChangeInput = jest.fn();
render(<DateTimePicker date={dateTime('2021-05-05 12:34:56')} onChange={onChangeInput} />);
// open the calendar + time picker
await userEvent.click(screen.getByLabelText('Time picker'));
// Click the calendar button
await userEvent.click(screen.getByRole('button', { name: 'Time picker' }));
// open the time of day overlay
await userEvent.click(screen.getAllByRole('textbox')[1]);
// Check the active day is the 5th
expect(screen.getByRole('button', { name: 'May 5, 2021' })).toHaveClass('react-calendar__tile--active');
// check the hour element is visible
const hourElement = screen.getAllByRole('button', {
name: '00',
})[0];
expect(hourElement).toBeVisible();
// open the time of day overlay
await userEvent.click(screen.getAllByRole('textbox')[1]);
// select the hour value and check it's still visible
await userEvent.click(hourElement);
expect(hourElement).toBeVisible();
// change the hour
await userEvent.click(
screen.getAllByRole('button', {
name: '00',
})[0]
);
// click outside the overlay and check the hour element is no longer visible
// Check the active day is the 5th
expect(screen.getByRole('button', { name: 'May 5, 2021' })).toHaveClass('react-calendar__tile--active');
// change the hour
await userEvent.click(
screen.getAllByRole('button', {
name: '23',
})[0]
);
// Check the active day is the 5th
expect(screen.getByRole('button', { name: 'May 5, 2021' })).toHaveClass('react-calendar__tile--active');
}
);
it.each(TEST_TIMEZONES)(
'should not reset the time when selecting a different day (timezone: %s)',
async (timeZone) => {
setTimeZoneResolver(() => timeZone);
const onChangeInput = jest.fn();
render(<DateTimePicker date={dateTime('2021-05-05 12:34:56')} onChange={onChangeInput} />);
// Click the calendar button
await userEvent.click(screen.getByRole('button', { name: 'Time picker' }));
// Select a different day in the calendar
await userEvent.click(screen.getByRole('button', { name: 'May 15, 2021' }));
const timeInput = screen.getAllByRole('textbox')[1];
expect(timeInput).toHaveClass('rc-time-picker-input');
expect(timeInput).not.toHaveDisplayValue('00:00:00');
}
);
it.each(TEST_TIMEZONES)(
'should always show the correct matching day in the calendar (timezone: %s)',
async (timeZone) => {
setTimeZoneResolver(() => timeZone);
const onChangeInput = jest.fn();
render(<DateTimePicker date={dateTime('2021-05-05T23:59:41.000000Z')} onChange={onChangeInput} />);
const dateTimeInputValue = screen.getByTestId(Components.DateTimePicker.input).getAttribute('value')!;
// takes the string from the input
// depending on the timezone, this will look something like 2024-04-05 19:59:41
// parses out the day value and strips the leading 0
const day = parseInt(dateTimeInputValue.split(' ')[0].split('-')[2], 10);
// Click the calendar button
await userEvent.click(screen.getByRole('button', { name: 'Time picker' }));
// Check the active day matches the input
expect(screen.getByRole('button', { name: `May ${day}, 2021` })).toHaveClass('react-calendar__tile--active');
}
);
it.each(TEST_TIMEZONES)(
'should always show the correct matching day when selecting a date in the calendar (timezone: %s)',
async (timeZone) => {
setTimeZoneResolver(() => timeZone);
const onChangeInput = jest.fn();
render(<DateTimePicker date={dateTime('2021-05-05T23:59:41.000000Z')} onChange={onChangeInput} />);
// Click the calendar button
await userEvent.click(screen.getByRole('button', { name: 'Time picker' }));
// Select a new day
const day = 8;
await userEvent.click(screen.getByRole('button', { name: `May ${day}, 2021` }));
await userEvent.click(screen.getByRole('button', { name: 'Apply' }));
const onChangeInputArg = onChangeInput.mock.calls[0][0];
expect(dateTimeAsMoment(dateTimeForTimeZone(timeZone, onChangeInputArg)).date()).toBe(day);
}
);
it.each(TEST_TIMEZONES)('should not alter a UTC time when blurring (timezone: %s)', async (timeZone) => {
setTimeZoneResolver(() => timeZone);
const onChangeInput = jest.fn();
// render with a UTC value
const { rerender } = render(
<DateTimePicker date={dateTime('2024-04-16T08:44:41.000000Z')} onChange={onChangeInput} />
);
const inputValue = screen.getByTestId(Components.DateTimePicker.input).getAttribute('value')!;
// blur the input to trigger an onChange
await userEvent.click(screen.getByTestId(Components.DateTimePicker.input));
await userEvent.click(document.body);
expect(
screen.queryByRole('button', {
name: '00',
})
).not.toBeInTheDocument();
const onChangeValue = onChangeInput.mock.calls[0][0];
expect(onChangeInput).toHaveBeenCalledWith(onChangeValue);
// now rerender with the "changed" value
rerender(<DateTimePicker date={onChangeValue} onChange={onChangeInput} />);
// expect the input to show the same value
expect(screen.getByTestId(Components.DateTimePicker.input)).toHaveDisplayValue(inputValue);
// blur the input to trigger an onChange
await userEvent.click(screen.getByTestId(Components.DateTimePicker.input));
await userEvent.click(document.body);
// expect the onChange to be called with the same value
expect(onChangeInput).toHaveBeenCalledWith(onChangeValue);
});
it.each(TEST_TIMEZONES)(
'should be able to select values in TimeOfDayPicker without blurring the element (timezone: %s)',
async (timeZone) => {
setTimeZoneResolver(() => timeZone);
renderDatetimePicker();
// open the calendar + time picker
await userEvent.click(screen.getByLabelText('Time picker'));
// open the time of day overlay
await userEvent.click(screen.getAllByRole('textbox')[1]);
// check the hour element is visible
const hourElement = screen.getAllByRole('button', {
name: '00',
})[0];
expect(hourElement).toBeVisible();
// select the hour value and check it's still visible
await userEvent.click(hourElement);
expect(hourElement).toBeVisible();
// click outside the overlay and check the hour element is no longer visible
await userEvent.click(document.body);
expect(
screen.queryByRole('button', {
name: '00',
})
).not.toBeInTheDocument();
}
);
});

View File

@ -7,7 +7,15 @@ import React, { FormEvent, ReactNode, useCallback, useEffect, useRef, useState }
import Calendar from 'react-calendar';
import { useMedia } from 'react-use';
import { dateTimeFormat, DateTime, dateTime, GrafanaTheme2, isDateTime } from '@grafana/data';
import {
dateTimeFormat,
DateTime,
dateTime,
GrafanaTheme2,
isDateTime,
dateTimeForTimeZone,
getTimeZone,
} from '@grafana/data';
import { Components } from '@grafana/e2e-selectors';
import { useStyles2, useTheme2 } from '../../../themes';
@ -21,6 +29,7 @@ import { Portal } from '../../Portal/Portal';
import { TimeOfDayPicker, POPUP_CLASS_NAME } from '../TimeOfDayPicker';
import { getBodyStyles } from '../TimeRangePicker/CalendarBody';
import { isValid } from '../utils';
import { adjustDateForReactCalendar } from '../utils/adjustDateForReactCalendar';
export interface Props {
/** Input date for the component */
@ -227,7 +236,7 @@ const DateTimeInput = React.forwardRef<HTMLInputElement, InputProps>(
const onBlur = useCallback(() => {
if (!internalDate.invalid) {
const date = dateTime(internalDate.value);
const date = dateTimeForTimeZone(getTimeZone(), internalDate.value);
onChange(date);
}
}, [internalDate, onChange]);
@ -276,9 +285,18 @@ const DateTimeCalendar = React.forwardRef<HTMLDivElement, DateTimeCalendarProps>
) => {
const calendarStyles = useStyles2(getBodyStyles);
const styles = useStyles2(getStyles);
const [internalDate, setInternalDate] = useState<Date>(() => {
// need to keep these 2 separate in state since react-calendar doesn't support different timezones
const [timeOfDayDateTime, setTimeOfDayDateTime] = useState(() => {
if (date && date.isValid()) {
return date.toDate();
return dateTimeForTimeZone(getTimeZone(), date);
}
return dateTimeForTimeZone(getTimeZone(), new Date());
});
const [reactCalendarDate, setReactCalendarDate] = useState<Date>(() => {
if (date && date.isValid()) {
return adjustDateForReactCalendar(date.toDate(), getTimeZone());
}
return new Date();
@ -286,28 +304,33 @@ const DateTimeCalendar = React.forwardRef<HTMLDivElement, DateTimeCalendarProps>
const onChangeDate = useCallback<NonNullable<React.ComponentProps<typeof Calendar>['onChange']>>((date) => {
if (date && !Array.isArray(date)) {
setInternalDate((prevState) => {
// If we don't use time from prevState
// the time will be reset to 00:00:00
date.setHours(prevState.getHours());
date.setMinutes(prevState.getMinutes());
date.setSeconds(prevState.getSeconds());
return date;
});
setReactCalendarDate(date);
}
}, []);
const onChangeTime = useCallback((date: DateTime) => {
setInternalDate(date.toDate());
setTimeOfDayDateTime(date);
}, []);
// here we need to stitch the 2 date objects back together
const handleApply = () => {
// we take the date that's set by TimeOfDayPicker
const newDate = dateTime(timeOfDayDateTime);
// and apply the date/month/year set by react-calendar
newDate.set('date', reactCalendarDate.getDate());
newDate.set('month', reactCalendarDate.getMonth());
newDate.set('year', reactCalendarDate.getFullYear());
onChange(newDate);
};
return (
<div className={cx(styles.container, { [styles.fullScreen]: isFullscreen })} style={style} ref={ref}>
<Calendar
next2Label={null}
prev2Label={null}
value={internalDate}
value={reactCalendarDate}
nextLabel={<Icon name="angle-right" />}
nextAriaLabel="Next month"
prevLabel={<Icon name="angle-left" />}
@ -323,14 +346,14 @@ const DateTimeCalendar = React.forwardRef<HTMLDivElement, DateTimeCalendarProps>
<TimeOfDayPicker
showSeconds={showSeconds}
onChange={onChangeTime}
value={dateTime(internalDate)}
value={timeOfDayDateTime}
disabledHours={disabledHours}
disabledMinutes={disabledMinutes}
disabledSeconds={disabledSeconds}
/>
</div>
<Stack>
<Button type="button" onClick={() => onChange(dateTime(internalDate))}>
<Button type="button" onClick={handleApply}>
Apply
</Button>
<Button variant="secondary" type="button" onClick={onClose}>

View File

@ -2,10 +2,11 @@ import { css } from '@emotion/css';
import React, { useCallback } from 'react';
import Calendar from 'react-calendar';
import { GrafanaTheme2, dateTimeParse, DateTime, TimeZone, getZone } from '@grafana/data';
import { GrafanaTheme2, dateTimeParse, DateTime, TimeZone } from '@grafana/data';
import { useStyles2 } from '../../../themes';
import { Icon } from '../../Icon/Icon';
import { adjustDateForReactCalendar } from '../utils/adjustDateForReactCalendar';
import { TimePickerCalendarProps } from './TimePickerCalendar';
@ -42,7 +43,8 @@ export function inputToValue(
let toAsDate = to.isValid() ? to.toDate() : invalidDateDefault;
if (timezone) {
[fromAsDate, toAsDate] = adjustDateForReactCalendar(fromAsDate, toAsDate, timezone);
fromAsDate = adjustDateForReactCalendar(fromAsDate, timezone);
toAsDate = adjustDateForReactCalendar(toAsDate, timezone);
}
if (fromAsDate > toAsDate) {
@ -52,39 +54,6 @@ export function inputToValue(
return [fromAsDate, toAsDate];
}
/**
* React calendar doesn't support showing ranges in other time zones, so attempting to show
* 10th midnight - 11th midnight in another time zone than your browsers will span three days
* instead of two.
*
* This function adjusts the dates by "moving" the time to appear as if it's local.
* e.g. make 5 PM New York "look like" 5 PM in the user's local browser time.
* See also https://github.com/wojtekmaj/react-calendar/issues/511#issuecomment-835333976
*/
function adjustDateForReactCalendar(from: Date, to: Date, timeZone: string): [Date, Date] {
const zone = getZone(timeZone);
if (!zone) {
return [from, to];
}
// get utc offset for timezone preference
const timezonePrefFromOffset = zone.utcOffset(from.getTime());
const timezonePrefToOffset = zone.utcOffset(to.getTime());
// get utc offset for local timezone
const localFromOffset = from.getTimezoneOffset();
const localToOffset = to.getTimezoneOffset();
// calculate difference between timezone preference and local timezone
// we keep these as separate variables in case one of them crosses a daylight savings boundary
const fromDiff = timezonePrefFromOffset - localFromOffset;
const toDiff = timezonePrefToOffset - localToOffset;
const newFromDate = new Date(from.getTime() - fromDiff * 1000 * 60);
const newToDate = new Date(to.getTime() - toDiff * 1000 * 60);
return [newFromDate, newToDate];
}
function useOnCalendarChange(onChange: (from: DateTime, to: DateTime) => void, timeZone?: TimeZone) {
return useCallback<NonNullable<React.ComponentProps<typeof Calendar>['onChange']>>(
(value) => {

View File

@ -0,0 +1,28 @@
import { getZone } from '@grafana/data';
/**
* React calendar doesn't support showing dates in other time zones, so attempting to show
* values near midnight in another time zone than your browsers may end up showing the wrong date
*
* This function adjusts a date by "moving" the time to appear as if it's local.
* e.g. make 5 PM New York "look like" 5 PM in the user's local browser time.
* See also https://github.com/wojtekmaj/react-calendar/issues/511#issuecomment-835333976
*/
export function adjustDateForReactCalendar(date: Date, timeZone: string): Date {
const zone = getZone(timeZone);
if (!zone) {
return date;
}
// get utc offset for timezone preference
const timezonePrefOffset = zone.utcOffset(date.getTime());
// get utc offset for local timezone
const localOffset = date.getTimezoneOffset();
// calculate difference between timezone preference and local timezone
const diff = timezonePrefOffset - localOffset;
const newDate = new Date(date.getTime() - diff * 1000 * 60);
return newDate;
}