From 45ff0b5cf68092e2173a9768edaf7a1f196a3681 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Wed, 23 Aug 2023 13:57:32 +0100 Subject: [PATCH] DatePicker: Fix calendar not showing correct selected range when changing time zones (#73273) * user essentials mob! :trident: * user essentials mob! :trident: lastFile:packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarBody.tsx * user essentials mob! :trident: lastFile:packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarBody.tsx * user essentials mob! :trident: * user essentials mob! :trident: lastFile:e2e/dashboards-suite/dashboard-timepicker.spec.ts * user essentials mob! :trident: lastFile:e2e/dashboards-suite/dashboard-timepicker.spec.ts * user essentials mob! :trident: * user essentials mob! :trident: * restore custom.ini * run betterer + prettier --------- Co-authored-by: joshhunt Co-authored-by: Roxana Turc Co-authored-by: Tobias Skarhed Co-authored-by: eledobleefe --- .betterer.results | 7 +-- .../dashboard-timepicker.spec.ts | 54 ++++++++++++++++ e2e/run-suite | 1 + .../grafana-data/src/datetime/timezones.ts | 4 ++ .../src/selectors/pages.ts | 3 + packages/grafana-e2e/package.json | 1 + packages/grafana-e2e/src/flows/index.ts | 1 + .../grafana-e2e/src/flows/revertAllChanges.ts | 6 +- .../grafana-e2e/src/flows/setTimeRange.ts | 1 + .../grafana-e2e/src/flows/userPreferences.ts | 25 ++++++++ packages/grafana-e2e/src/support/scenario.ts | 5 +- .../src/support/scenarioContext.ts | 8 +-- .../TimeRangePicker/CalendarBody.tsx | 61 ++++++++++++++++--- .../TimeRangePicker/TimeRangeContent.tsx | 4 +- yarn.lock | 1 + 15 files changed, 159 insertions(+), 23 deletions(-) create mode 100644 e2e/dashboards-suite/dashboard-timepicker.spec.ts create mode 100644 packages/grafana-e2e/src/flows/userPreferences.ts diff --git a/.betterer.results b/.betterer.results index 7126b067ccd..0ca5970d53b 100644 --- a/.betterer.results +++ b/.betterer.results @@ -758,8 +758,7 @@ exports[`better eslint`] = { ], "packages/grafana-e2e/src/flows/revertAllChanges.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "packages/grafana-e2e/src/flows/selectOption.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -774,9 +773,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "5"] ], "packages/grafana-e2e/src/support/scenarioContext.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "packages/grafana-e2e/src/support/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], diff --git a/e2e/dashboards-suite/dashboard-timepicker.spec.ts b/e2e/dashboards-suite/dashboard-timepicker.spec.ts new file mode 100644 index 00000000000..d419b2b16fb --- /dev/null +++ b/e2e/dashboards-suite/dashboard-timepicker.spec.ts @@ -0,0 +1,54 @@ +import { e2e } from '@grafana/e2e'; + +e2e.scenario({ + describeName: 'Dashboard timepicker', + itName: 'Shows the correct calendar days with custom timezone set via preferences', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + e2e.flows.setUserPreferences({ + timezone: 'Asia/Tokyo', + }); + + // Open dashboard with time range from 8th to end of 10th. + // Will be Tokyo time because of above preference + e2e.flows.openDashboard({ + uid: '5SdHCasdf', + timeRange: { + zone: 'Default', + from: '2022-06-08 00:00:00', + to: '2022-06-10 23:59:59', + }, + }); + + // Assert that the calendar shows 08 and 09 and 10 as selected days + e2e.components.TimePicker.openButton().click(); + e2e.components.TimePicker.calendar.openButton().first().click(); + e2e().get('.react-calendar__tile--active, .react-calendar__tile--hasActive').should('have.length', 3); + }, +}); + +e2e.scenario({ + describeName: 'Dashboard timepicker', + itName: 'Shows the correct calendar days with custom timezone set via time picker', + addScenarioDataSource: false, + addScenarioDashBoard: false, + skipScenario: false, + scenario: () => { + // Open dashboard with time range from 2022-06-08 00:00:00 to 2022-06-10 23:59:59 in Tokyo time + e2e.flows.openDashboard({ + uid: '5SdHCasdf', + timeRange: { + zone: 'Asia/Tokyo', + from: '2022-06-08 00:00:00', + to: '2022-06-10 23:59:59', + }, + }); + + // Assert that the calendar shows 08 and 09 and 10 as selected days + e2e.components.TimePicker.openButton().click(); + e2e.components.TimePicker.calendar.openButton().first().click(); + e2e().get('.react-calendar__tile--active, .react-calendar__tile--hasActive').should('have.length', 3); + }, +}); diff --git a/e2e/run-suite b/e2e/run-suite index 8c446f3145b..407752c1eb7 100755 --- a/e2e/run-suite +++ b/e2e/run-suite @@ -115,6 +115,7 @@ function join () { echo "$res" } +export TZ="Pacific/Honolulu" yarn $CMD --env "$(join env)" \ --config "$(join cypressConfig)" \ diff --git a/packages/grafana-data/src/datetime/timezones.ts b/packages/grafana-data/src/datetime/timezones.ts index 3e8bc9262eb..f1cca497c55 100644 --- a/packages/grafana-data/src/datetime/timezones.ts +++ b/packages/grafana-data/src/datetime/timezones.ts @@ -22,6 +22,10 @@ export const timeZoneFormatUserFriendly = (timeZone: TimeZone | undefined) => { } }; +export const getZone = (timeZone: string) => { + return moment.tz.zone(timeZone); +}; + export interface TimeZoneCountry { code: string; name: string; diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 769467c59af..ac6a7e7e30f 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -320,4 +320,7 @@ export const Pages = { }, }, }, + ProfilePage: { + url: '/profile', + }, }; diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json index 4601ef4e9b2..1393be1d520 100644 --- a/packages/grafana-e2e/package.json +++ b/packages/grafana-e2e/package.json @@ -64,6 +64,7 @@ "@babel/preset-env": "7.22.9", "@cypress/webpack-preprocessor": "5.17.1", "@grafana/e2e-selectors": "10.2.0-pre", + "@grafana/schema": "10.2.0-pre", "@grafana/tsconfig": "^1.2.0-rc1", "@mochajs/json-file-reporter": "^1.2.0", "babel-loader": "9.1.3", diff --git a/packages/grafana-e2e/src/flows/index.ts b/packages/grafana-e2e/src/flows/index.ts index 1fc8212c628..e699542788f 100644 --- a/packages/grafana-e2e/src/flows/index.ts +++ b/packages/grafana-e2e/src/flows/index.ts @@ -13,6 +13,7 @@ export * from './saveDashboard'; export * from './selectOption'; export * from './importDashboard'; export * from './importDashboards'; +export * from './userPreferences'; export { VISUALIZATION_ALERT_LIST, diff --git a/packages/grafana-e2e/src/flows/revertAllChanges.ts b/packages/grafana-e2e/src/flows/revertAllChanges.ts index 8fda82d2c60..94b2e966bd6 100644 --- a/packages/grafana-e2e/src/flows/revertAllChanges.ts +++ b/packages/grafana-e2e/src/flows/revertAllChanges.ts @@ -1,8 +1,12 @@ import { e2e } from '../index'; export const revertAllChanges = () => { - e2e.getScenarioContext().then(({ addedDashboards, addedDataSources }: any) => { + e2e.getScenarioContext().then(({ addedDashboards, addedDataSources, hasChangedUserPreferences }) => { addedDashboards.forEach((dashboard: any) => e2e.flows.deleteDashboard({ ...dashboard, quick: true })); addedDataSources.forEach((dataSource: any) => e2e.flows.deleteDataSource({ ...dataSource, quick: true })); + + if (hasChangedUserPreferences) { + e2e.flows.setDefaultUserPreferences(); + } }); }; diff --git a/packages/grafana-e2e/src/flows/setTimeRange.ts b/packages/grafana-e2e/src/flows/setTimeRange.ts index ad5b1d84081..fe3528fea19 100644 --- a/packages/grafana-e2e/src/flows/setTimeRange.ts +++ b/packages/grafana-e2e/src/flows/setTimeRange.ts @@ -13,6 +13,7 @@ export const setTimeRange = ({ from, to, zone }: TimeRangeConfig) => { if (zone) { e2e().contains('button', 'Change time settings').click(); + e2e().log('setting time zone to ' + zone); if (e2e.components.TimeZonePicker.containerV2) { selectOption({ diff --git a/packages/grafana-e2e/src/flows/userPreferences.ts b/packages/grafana-e2e/src/flows/userPreferences.ts new file mode 100644 index 00000000000..621bff04ce6 --- /dev/null +++ b/packages/grafana-e2e/src/flows/userPreferences.ts @@ -0,0 +1,25 @@ +import { Preferences as UserPreferencesDTO } from '@grafana/schema/src/raw/preferences/x/preferences_types.gen'; + +import { e2e } from '..'; +import { fromBaseUrl } from '../support/url'; + +const defaultUserPreferences = { + timezone: '', // "Default" option +} as const; // TODO: when we update typescript >4.9 change to `as const satisfies UserPreferencesDTO` + +// Only accept preferences we have defaults for as arguments. To allow a new preference to be set, add a default for it +type UserPreferences = Pick; + +export function setUserPreferences(prefs: UserPreferences) { + e2e.setScenarioContext({ hasChangedUserPreferences: prefs !== defaultUserPreferences }); + + return cy.request({ + method: 'PUT', + url: fromBaseUrl('/api/user/preferences'), + body: prefs, + }); +} + +export function setDefaultUserPreferences() { + return setUserPreferences(defaultUserPreferences); +} diff --git a/packages/grafana-e2e/src/support/scenario.ts b/packages/grafana-e2e/src/support/scenario.ts index b4926b5bfbc..85155ef365f 100644 --- a/packages/grafana-e2e/src/support/scenario.ts +++ b/packages/grafana-e2e/src/support/scenario.ts @@ -23,7 +23,10 @@ export const e2eScenario = ({ if (skipScenario) { it.skip(itName, () => scenario()); } else { - before(() => e2e.flows.login(e2e.env('USERNAME'), e2e.env('PASSWORD'), loginViaApi)); + before(() => { + e2e.flows.login(e2e.env('USERNAME'), e2e.env('PASSWORD'), loginViaApi); + e2e.flows.setDefaultUserPreferences(); + }); beforeEach(() => { Cypress.Cookies.preserveOnce('grafana_session'); diff --git a/packages/grafana-e2e/src/support/scenarioContext.ts b/packages/grafana-e2e/src/support/scenarioContext.ts index 510408a2b56..741588da523 100644 --- a/packages/grafana-e2e/src/support/scenarioContext.ts +++ b/packages/grafana-e2e/src/support/scenarioContext.ts @@ -9,12 +9,14 @@ export interface ScenarioContext { lastAddedDashboardUid: string; lastAddedDataSource: string; // @todo rename to `lastAddedDataSourceName` lastAddedDataSourceId: string; + hasChangedUserPreferences: boolean; [key: string]: any; } const scenarioContext: ScenarioContext = { addedDashboards: [], addedDataSources: [], + hasChangedUserPreferences: false, get lastAddedDashboard() { return lastProperty(this.addedDashboards, 'title'); }, @@ -34,8 +36,7 @@ const lastProperty = items[items.length - 1]?.[key] ?? ''; -// @todo this actually returns type `Cypress.Chainable` -export const getScenarioContext = (): any => +export const getScenarioContext = (): Cypress.Chainable => e2e() .wrap( { @@ -45,8 +46,7 @@ export const getScenarioContext = (): any => ) .invoke({ log: false }, 'getScenarioContext'); -// @todo this actually returns type `Cypress.Chainable` -export const setScenarioContext = (newContext: Partial): any => +export const setScenarioContext = (newContext: Partial): Cypress.Chainable => e2e() .wrap( { diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarBody.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarBody.tsx index aa3cb9eaa53..f115d536d85 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarBody.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/CalendarBody.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import React, { useCallback } from 'react'; import Calendar from 'react-calendar'; -import { GrafanaTheme2, dateTime, dateTimeParse, DateTime, TimeZone } from '@grafana/data'; +import { GrafanaTheme2, dateTimeParse, DateTime, TimeZone, getZone } from '@grafana/data'; import { useStyles2 } from '../../../themes'; import { Icon } from '../../Icon/Icon'; @@ -10,7 +10,7 @@ import { Icon } from '../../Icon/Icon'; import { TimePickerCalendarProps } from './TimePickerCalendar'; export function Body({ onChange, from, to, timeZone }: TimePickerCalendarProps) { - const value = inputToValue(from, to); + const value = inputToValue(from, to, new Date(), timeZone); const onCalendarChange = useOnCalendarChange(onChange, timeZone); const styles = useStyles2(getBodyStyles); @@ -32,16 +32,57 @@ export function Body({ onChange, from, to, timeZone }: TimePickerCalendarProps) Body.displayName = 'Body'; -export function inputToValue(from: DateTime, to: DateTime, invalidDateDefault: Date = new Date()): [Date, Date] { - const fromAsDate = from.toDate(); - const toAsDate = to.toDate(); - const fromAsValidDate = dateTime(fromAsDate).isValid() ? fromAsDate : invalidDateDefault; - const toAsValidDate = dateTime(toAsDate).isValid() ? toAsDate : invalidDateDefault; +export function inputToValue( + from: DateTime, + to: DateTime, + invalidDateDefault: Date = new Date(), + timezone?: string +): [Date, Date] { + let fromAsDate = from.isValid() ? from.toDate() : invalidDateDefault; + let toAsDate = to.isValid() ? to.toDate() : invalidDateDefault; - if (fromAsValidDate > toAsValidDate) { - return [toAsValidDate, fromAsValidDate]; + if (timezone) { + [fromAsDate, toAsDate] = adjustDateForReactCalendar(fromAsDate, toAsDate, timezone); } - return [fromAsValidDate, toAsValidDate]; + + if (fromAsDate > toAsDate) { + return [toAsDate, fromAsDate]; + } + + 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) { diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx index 1ee679065a4..af28f36a475 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx @@ -159,8 +159,8 @@ export const TimeRangeContent = (props: Props) => { setOpen(false)} onChange={onChange} diff --git a/yarn.lock b/yarn.lock index 14dbad72cd3..f381880290b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3719,6 +3719,7 @@ __metadata: "@babel/preset-env": 7.22.9 "@cypress/webpack-preprocessor": 5.17.1 "@grafana/e2e-selectors": 10.2.0-pre + "@grafana/schema": 10.2.0-pre "@grafana/tsconfig": ^1.2.0-rc1 "@mochajs/json-file-reporter": ^1.2.0 "@rollup/plugin-node-resolve": 15.1.0