diff --git a/packages/grafana-data/src/datetime/rangeutil.ts b/packages/grafana-data/src/datetime/rangeutil.ts index 680ce22dcf0..d3d24de56e8 100644 --- a/packages/grafana-data/src/datetime/rangeutil.ts +++ b/packages/grafana-data/src/datetime/rangeutil.ts @@ -505,3 +505,14 @@ export function relativeToTimeRange(relativeTimeRange: RelativeTimeRange, now: D raw: { from, to }, }; } + +/** + * @internal + * Returns a RawTimeRange that has been converted so that from and to are strings + */ +export function formatRawTimeRange(range: RawTimeRange): RawTimeRange { + return { + from: isDateTime(range.from) ? range.from.toISOString() : range.from, + to: isDateTime(range.to) ? range.to.toISOString() : range.to, + }; +} diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx index d564a28b8ee..c244fc15e44 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { dateTimeParse, FeatureToggles, systemDateFormats, TimeRange } from '@grafana/data'; @@ -66,6 +66,8 @@ function setup(initial: TimeRange = defaultTimeRange, timeZone = 'utc') { describe('TimeRangeForm', () => { let user: ReturnType; beforeEach(() => { + mockClipboard.writeText.mockClear(); + mockClipboard.readText.mockClear(); user = userEvent.setup(); Object.defineProperty(global.navigator, 'clipboard', { value: mockClipboard, @@ -117,6 +119,37 @@ describe('TimeRangeForm', () => { expect(getByLabelText('To')).toHaveValue('2021-06-19 19:59:00'); }); + it('copy in UTC then paste into different timezone should convert times', async () => { + const sourceRange: TimeRange = { + from: defaultTimeRange.from, + to: defaultTimeRange.to, + raw: { + from: defaultTimeRange.from, + to: defaultTimeRange.to, + }, + }; + + const source = setup(sourceRange); + + let written = ''; + mockClipboard.writeText.mockImplementation((text: string) => { + written = text; + return Promise.resolve(); + }); + + await user.click(within(source.container).getByTestId('data-testid TimePicker copy button')); + + const target = setup(undefined, 'America/New_York'); + + mockClipboard.readText.mockResolvedValue(written); + + const targetPasteButton = within(target.container).getByTestId('data-testid TimePicker paste button'); + await user.click(targetPasteButton); + + expect(within(target.container).getByLabelText('From')).toHaveValue('2021-06-16 20:00:00'); + expect(within(target.container).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(); diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx index 7f6438af66a..5b732ab8aa9 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx @@ -114,8 +114,9 @@ export const TimeRangeContent = (props: Props) => { }; const onCopy = () => { - const raw: RawTimeRange = { from: from.value, to: to.value }; - navigator.clipboard.writeText(JSON.stringify(raw)); + const rawSource: RawTimeRange = value.raw; + const clipboardPayload = rangeUtil.formatRawTimeRange(rawSource); + navigator.clipboard.writeText(JSON.stringify(clipboardPayload)); }; const onPaste = async () => { diff --git a/public/app/core/utils/timePicker.ts b/public/app/core/utils/timePicker.ts index ba862fb1858..de5e98781bc 100644 --- a/public/app/core/utils/timePicker.ts +++ b/public/app/core/utils/timePicker.ts @@ -1,4 +1,6 @@ -import { TimeRange, toUtc, AbsoluteTimeRange, RawTimeRange } from '@grafana/data'; +import { isString } from 'lodash'; + +import { TimeRange, toUtc, AbsoluteTimeRange, RawTimeRange, dateTime, DateTime } from '@grafana/data'; type CopiedTimeRangeResult = { range: RawTimeRange; isError: false } | { range: string; isError: true }; @@ -57,3 +59,10 @@ export async function getCopiedTimeRange(): Promise { return { range: raw, isError: true }; } } + +export const toUtcDateTimeIfIsoString = (value: string | DateTime): string | DateTime => { + if (isString(value) && value.includes('Z')) { + return dateTime(value).utc(); + } + return value; +}; diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts index c7f40f28675..224be7762b7 100644 --- a/public/app/features/dashboard/services/TimeSrv.ts +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -1,8 +1,7 @@ -import { cloneDeep, extend, isString } from 'lodash'; +import { cloneDeep, extend } from 'lodash'; import { dateMath, - dateTime, getDefaultTimeRange, isDateTime, rangeUtil, @@ -19,7 +18,12 @@ import { sceneGraph } from '@grafana/scenes'; import appEvents from 'app/core/app_events'; import { config } from 'app/core/config'; import { AutoRefreshInterval, contextSrv, ContextSrv } from 'app/core/services/context_srv'; -import { getCopiedTimeRange, getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; +import { + getCopiedTimeRange, + getShiftedTimeRange, + getZoomedTimeRange, + toUtcDateTimeIfIsoString, +} from 'app/core/utils/timePicker'; import { getTimeRange } from 'app/features/dashboard/utils/timeRange'; import { @@ -99,12 +103,8 @@ export class TimeSrv { private parseTime() { // when absolute time is saved in json it is turned to a string - if (isString(this.time.from) && this.time.from.indexOf('Z') >= 0) { - this.time.from = dateTime(this.time.from).utc(); - } - if (isString(this.time.to) && this.time.to.indexOf('Z') >= 0) { - this.time.to = dateTime(this.time.to).utc(); - } + this.time.from = toUtcDateTimeIfIsoString(this.time.from); + this.time.to = toUtcDateTimeIfIsoString(this.time.to); } private parseUrlParam(value: string, timeZone?: string) { @@ -378,7 +378,8 @@ export class TimeSrv { copyTimeRangeToClipboard() { const { raw } = this.timeRange(); - navigator.clipboard.writeText(JSON.stringify({ from: raw.from, to: raw.to })); + const clipboardPayload = rangeUtil.formatRawTimeRange(raw); + navigator.clipboard.writeText(JSON.stringify(clipboardPayload)); appEvents.emit(AppEvents.alertSuccess, [ t('time-picker.copy-paste.copy-success-message', 'Time range copied to clipboard'), ]); @@ -395,7 +396,11 @@ export class TimeSrv { return; } - const { from, to } = range; + let { from, to } = range; + + // if ISO-8601 UTC string (which include 'Z') is pasted, convert them to DateTime.utc + from = toUtcDateTimeIfIsoString(from); + to = toUtcDateTimeIfIsoString(to); this.setTime({ from, to }, updateUrl); } diff --git a/public/app/features/explore/state/time.ts b/public/app/features/explore/state/time.ts index 536c51f9f96..a63ce86c44d 100644 --- a/public/app/features/explore/state/time.ts +++ b/public/app/features/explore/state/time.ts @@ -5,6 +5,7 @@ import { AppEvents, dateTimeForTimeZone, LoadingState, + rangeUtil, RawTimeRange, TimeRange, } from '@grafana/data'; @@ -13,7 +14,12 @@ import { getTemplateSrv } from '@grafana/runtime'; import { RefreshPicker } from '@grafana/ui'; import appEvents from 'app/core/app_events'; import { getTimeRange, refreshIntervalToSortOrder, stopQueryState } from 'app/core/utils/explore'; -import { getCopiedTimeRange, getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; +import { + getCopiedTimeRange, + getShiftedTimeRange, + getZoomedTimeRange, + toUtcDateTimeIfIsoString, +} from 'app/core/utils/timePicker'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { sortLogsResult } from 'app/features/logs/utils'; import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors'; @@ -179,7 +185,8 @@ export function zoomOut(scale: number): ThunkResult { export function copyTimeRangeToClipboard(): ThunkResult { return (dispatch, getState) => { const range = getState().explore.panes[Object.keys(getState().explore.panes)[0]]!.range.raw; - navigator.clipboard.writeText(JSON.stringify(range)); + const clipboardPayload = rangeUtil.formatRawTimeRange(range); + navigator.clipboard.writeText(JSON.stringify(clipboardPayload)); appEvents.emit(AppEvents.alertSuccess, [ t('time-picker.copy-paste.copy-success-message', 'Time range copied to clipboard'), @@ -199,15 +206,20 @@ export function pasteTimeRangeFromClipboard(): ThunkResult { return; } + const utcRange = { + from: toUtcDateTimeIfIsoString(range.from), + to: toUtcDateTimeIfIsoString(range.to), + }; + const panesSynced = getState().explore.syncedTimes; if (panesSynced) { - dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[0], rawRange: range })); - dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[1], rawRange: range })); + dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[0], rawRange: utcRange })); + dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[1], rawRange: utcRange })); return; } - dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[0], rawRange: range })); + dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[0], rawRange: utcRange })); }; }