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:
co-authored by
Copilot
parent
a746f6e121
commit
be3fa041a5
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+34
-1
@@ -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();
|
||||
|
||||
+3
-2
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user