mirror of
https://github.com/grafana/grafana.git
synced 2025-02-14 09:33:34 -06:00
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:
parent
ccd2bff8b0
commit
7fab894e9b
@ -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;
|
||||
|
@ -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();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -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}>
|
||||
|
@ -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) => {
|
||||
|
@ -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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user