Skip to content

Commit

Permalink
[v11.0.x] DateTimePicker: Alternate timezones now behave correctly (#…
Browse files Browse the repository at this point in the history
…87041)

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>
(cherry picked from commit 7fab894)

Co-authored-by: Thomas Wikman <thomas@w1kman.se>
  • Loading branch information
ashharrison90 and w1kman committed Apr 29, 2024
1 parent 8187058 commit e0a06c6
Show file tree
Hide file tree
Showing 5 changed files with 254 additions and 79 deletions.
2 changes: 1 addition & 1 deletion packages/grafana-data/src/datetime/moment_wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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} />);

// Click the calendar button
await userEvent.click(screen.getByRole('button', { name: 'Time picker' }));

// Check the active day is the 5th
expect(screen.getByRole('button', { name: 'May 5, 2021' })).toHaveClass('react-calendar__tile--active');

// open the time of day overlay
await userEvent.click(screen.getAllByRole('textbox')[1]);

// change the hour
await userEvent.click(
screen.getAllByRole('button', {
name: '00',
})[0]
);

// open the calendar + time picker
await userEvent.click(screen.getByLabelText('Time picker'));
// Check the active day is the 5th
expect(screen.getByRole('button', { name: 'May 5, 2021' })).toHaveClass('react-calendar__tile--active');

// open the time of day overlay
await userEvent.click(screen.getAllByRole('textbox')[1]);
// change the hour
await userEvent.click(
screen.getAllByRole('button', {
name: '23',
})[0]
);

// check the hour element is visible
const hourElement = screen.getAllByRole('button', {
name: '00',
})[0];
expect(hourElement).toBeVisible();
// 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');
}
);

// select the hour value and check it's still visible
await userEvent.click(hourElement);
expect(hourElement).toBeVisible();
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 outside the overlay and check the hour element is no longer visible
// 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();
}
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 */
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -276,38 +285,52 @@ 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 dateTimeForTimeZone(getTimeZone(), date);
}

return dateTimeForTimeZone(getTimeZone(), new Date());
});
const [reactCalendarDate, setReactCalendarDate] = useState<Date>(() => {
if (date && date.isValid()) {
return date.toDate();
return adjustDateForReactCalendar(date.toDate(), getTimeZone());
}

return new Date();
});

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" />}
Expand All @@ -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>
<HorizontalGroup>
<Button type="button" onClick={() => onChange(dateTime(internalDate))}>
<Button type="button" onClick={handleApply}>
Apply
</Button>
<Button variant="secondary" type="button" onClick={onClose}>
Expand Down
Loading

0 comments on commit e0a06c6

Please sign in to comment.