Dashboards: Conserve timestamp on time range copy-paste across timezones (#109769)

* fix(timepicker): preserve UTC timestamp on copy-paste across timezones

* test: add UTC copy/paste timezone conversion test

* Update packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove duplicate mockClipboard clear

* Extract utility functions for formatting and converting time ranges

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Alikamran Rzayev
2025-08-29 16:03:34 +02:00
committed by GitHub
co-authored by Copilot
parent a746f6e121
commit be3fa041a5
6 changed files with 91 additions and 20 deletions
@@ -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,
};
}
@@ -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<typeof userEvent.setup>;
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();
@@ -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 () => {
+10 -1
View File
@@ -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<CopiedTimeRangeResult> {
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;
};
@@ -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);
}
+17 -5
View File
@@ -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<void> {
export function copyTimeRangeToClipboard(): ThunkResult<void> {
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<void> {
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 }));
};
}