From f285eb6717de7d40a07dd091387238866ad08096 Mon Sep 17 00:00:00 2001
From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com>
Date: Thu, 18 Jan 2024 14:06:27 -0700
Subject: [PATCH] Time Range: Copy-paste Time Range (#80107)
* Working copy-paste functionality with validation
* WIP; uses 't c' shortcut to copy time range
* shortcuts working for explore and dashboards
* cleanup
* Don't update url when pasting in explore
* Error handling, sync pane functionality, add to help modal
* cleanup
* add tests
* fix i18n
* Diferrentiate between explore and dashboard paste events; make on error prop generic
* Fix
* extract getting the copied time range logic into a function
* Remove comments
* Make error handling generic; markup for translations
* Additional translation markup
* markup for aria-label
* Fix test
* Replace fireEvent with userEvent
* fix translations to match the standard pattern
* Refactor keybindingSrv and TimeSrv to remove PasteTimeContext
* Fix test
* Remove unneccessary aria labels; update icons; buttons inline
---
.../src/selectors/components.ts | 2 +
.../DateTimePickers/TimeRangePicker.tsx | 3 +
.../TimeRangePicker/TimePickerContent.tsx | 14 ++++-
.../TimeRangePicker/TimeRangeContent.test.tsx | 37 ++++++++++-
.../TimeRangePicker/TimeRangeContent.tsx | 63 +++++++++++++++++--
.../TimePicker/TimePickerWithHistory.tsx | 10 ++-
public/app/core/components/help/HelpModal.tsx | 8 +++
public/app/core/services/keybindingSrv.ts | 10 +++
public/app/core/utils/timePicker.ts | 21 ++++++-
.../features/dashboard/services/TimeSrv.ts | 45 ++++++++++++-
.../hooks/useKeyboardShortcuts.test.tsx | 47 +++++++++++++-
.../explore/hooks/useKeyboardShortcuts.ts | 22 ++++++-
public/app/features/explore/state/time.ts | 48 +++++++++++++-
public/app/types/events.ts | 12 ++++
public/locales/de-DE/grafana.json | 9 +++
public/locales/en-US/grafana.json | 9 +++
public/locales/es-ES/grafana.json | 9 +++
public/locales/fr-FR/grafana.json | 9 +++
public/locales/pseudo-LOCALE/grafana.json | 9 +++
public/locales/zh-Hans/grafana.json | 9 +++
20 files changed, 378 insertions(+), 18 deletions(-)
diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts
index a4f953ae7d9..0ec2247a52b 100644
--- a/packages/grafana-e2e-selectors/src/selectors/components.ts
+++ b/packages/grafana-e2e-selectors/src/selectors/components.ts
@@ -22,6 +22,8 @@ export const Components = {
fromField: 'data-testid Time Range from field',
toField: 'data-testid Time Range to field',
applyTimeRange: 'data-testid TimePicker submit button',
+ copyTimeRange: 'data-testid TimePicker copy button',
+ pasteTimeRange: 'data-testid TimePicker paste button',
calendar: {
label: 'data-testid Time Range calendar',
openButton: 'data-testid Open time range calendar',
diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx
index f350accba92..0954bd99512 100644
--- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx
+++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx
@@ -39,6 +39,7 @@ export interface TimeRangePickerProps {
onMoveBackward: () => void;
onMoveForward: () => void;
onZoom: () => void;
+ onError?: (error?: string) => void;
history?: TimeRange[];
hideQuickRanges?: boolean;
widthOverride?: number;
@@ -58,6 +59,7 @@ export function TimeRangePicker(props: TimeRangePickerProps) {
onMoveBackward,
onMoveForward,
onZoom,
+ onError,
timeZone,
fiscalYearStartMonth,
timeSyncButton,
@@ -165,6 +167,7 @@ export function TimeRangePicker(props: TimeRangePickerProps) {
onChangeTimeZone={onChangeTimeZone}
onChangeFiscalYearStartMonth={onChangeFiscalYearStartMonth}
hideQuickRanges={hideQuickRanges}
+ onError={onError}
/>
diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx
index a0bc70b19cc..36de6fd4308 100644
--- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx
+++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx
@@ -22,6 +22,7 @@ interface Props {
onChange: (timeRange: TimeRange) => void;
onChangeTimeZone: (timeZone: TimeZone) => void;
onChangeFiscalYearStartMonth?: (month: number) => void;
+ onError?: (error?: string) => void;
timeZone?: TimeZone;
fiscalYearStartMonth?: number;
quickOptions?: TimeOption[];
@@ -122,7 +123,7 @@ export const TimePickerContent = (props: Props) => {
};
const NarrowScreenForm = (props: FormProps) => {
- const { value, hideQuickRanges, onChange, timeZone, historyOptions = [], showHistory } = props;
+ const { value, hideQuickRanges, onChange, timeZone, historyOptions = [], showHistory, onError } = props;
const styles = useStyles2(getNarrowScreenStyles);
const isAbsolute = isDateTime(value.raw.from) || isDateTime(value.raw.to);
const [collapsedFlag, setCollapsedFlag] = useState(!isAbsolute);
@@ -156,7 +157,13 @@ const NarrowScreenForm = (props: FormProps) => {
{!collapsed && (
-
+
{showHistory && (
{
};
const FullScreenForm = (props: FormProps) => {
- const { onChange, value, timeZone, fiscalYearStartMonth, isReversed, historyOptions } = props;
+ const { onChange, value, timeZone, fiscalYearStartMonth, isReversed, historyOptions, onError } = props;
const styles = useStyles2(getFullScreenStyles, props.hideQuickRanges);
const onChangeTimeOption = (timeOption: TimeOption) => {
return onChange(mapOptionToTimeRange(timeOption, timeZone));
@@ -194,6 +201,7 @@ const FullScreenForm = (props: FormProps) => {
onApply={onChange}
isFullscreen={true}
isReversed={isReversed}
+ onError={onError}
/>
{props.showHistory && (
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 a03b7c5ea7d..40a1964b224 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,5 @@
import { fireEvent, render, RenderResult } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import React from 'react';
import { dateTimeParse, TimeRange } from '@grafana/data';
@@ -10,6 +11,15 @@ type TimeRangeFormRenderResult = RenderResult & {
getCalendarDayByLabelText(label: string): HTMLButtonElement;
};
+const mockClipboard = {
+ writeText: jest.fn(),
+ readText: jest.fn(),
+};
+
+Object.defineProperty(global.navigator, 'clipboard', {
+ value: mockClipboard,
+});
+
const defaultTimeRange: TimeRange = {
from: dateTimeParse('2021-06-17 00:00:00', { timeZone: 'utc' }),
to: dateTimeParse('2021-06-19 23:59:00', { timeZone: 'utc' }),
@@ -19,6 +29,11 @@ const defaultTimeRange: TimeRange = {
},
};
+const customRawTimeRange = {
+ from: '2023-06-17 00:00:00',
+ to: '2023-06-19 23:59:00',
+};
+
function setup(initial: TimeRange = defaultTimeRange, timeZone = 'utc'): TimeRangeFormRenderResult {
const result = render(
{}} timeZone={timeZone} />
@@ -34,7 +49,7 @@ function setup(initial: TimeRange = defaultTimeRange, timeZone = 'utc'): TimeRan
}
describe('TimeRangeForm', () => {
- it('should render form correcty', () => {
+ it('should render form correctly', () => {
const { getByLabelText, getByText, getAllByRole } = setup();
expect(getByText('Apply time range')).toBeInTheDocument();
@@ -105,6 +120,26 @@ describe('TimeRangeForm', () => {
expect(to).toHaveClass('react-calendar__tile--rangeEnd');
});
+ it('should copy time range to clipboard', async () => {
+ const { getByTestId } = setup();
+
+ await userEvent.click(getByTestId('data-testid TimePicker copy button'));
+ expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith(
+ JSON.stringify({ from: defaultTimeRange.raw.from, to: defaultTimeRange.raw.to })
+ );
+ });
+
+ it('should paste time range from clipboard', async () => {
+ const { getByTestId, getByLabelText } = setup();
+
+ mockClipboard.readText.mockResolvedValue(JSON.stringify(customRawTimeRange));
+
+ await userEvent.click(getByTestId('data-testid TimePicker paste button'));
+
+ expect(getByLabelText('From')).toHaveValue(customRawTimeRange.from);
+ expect(getByLabelText('To')).toHaveValue(customRawTimeRange.to);
+ });
+
describe('dates error handling', () => {
it('should show error on invalid dates', () => {
const invalidTimeRange: TimeRange = {
diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx
index 4cbf3748b4d..d5a53aa10f3 100644
--- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx
+++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx
@@ -33,6 +33,7 @@ interface Props {
fiscalYearStartMonth?: number;
roundup?: boolean;
isReversed?: boolean;
+ onError?: (error?: string) => void;
}
interface InputState {
@@ -47,7 +48,15 @@ const ERROR_MESSAGES = {
};
export const TimeRangeContent = (props: Props) => {
- const { value, isFullscreen = false, timeZone, onApply: onApplyFromProps, isReversed, fiscalYearStartMonth } = props;
+ const {
+ value,
+ isFullscreen = false,
+ timeZone,
+ onApply: onApplyFromProps,
+ isReversed,
+ fiscalYearStartMonth,
+ onError,
+ } = props;
const [fromValue, toValue] = valueToState(value.raw.from, value.raw.to, timeZone);
const style = useStyles2(getStyles);
@@ -99,6 +108,29 @@ export const TimeRangeContent = (props: Props) => {
}
};
+ const onCopy = () => {
+ const raw: RawTimeRange = { from: from.value, to: to.value };
+ navigator.clipboard.writeText(JSON.stringify(raw));
+ };
+
+ const onPaste = async () => {
+ const raw = await navigator.clipboard.readText();
+ let range;
+
+ try {
+ range = JSON.parse(raw);
+ } catch (error) {
+ if (onError) {
+ onError(raw);
+ }
+ return;
+ }
+
+ const [fromValue, toValue] = valueToState(range.from, range.to, timeZone);
+ setFrom(fromValue);
+ setTo(toValue);
+ };
+
const fiscalYear = rangeUtil.convertRawToRange({ from: 'now/fy', to: 'now/fy' }, timeZone, fiscalYearStartMonth);
const fiscalYearMessage = t('time-picker.range-content.fiscal-year', 'Fiscal year');
@@ -159,9 +191,27 @@ export const TimeRangeContent = (props: Props) => {
{fyTooltip}
-
+
+
+
+
+
{
onAppendToHistory(value, values, onSaveToStore);
props.onChange(value);
}}
+ onError={(error?: string) =>
+ appEvents.emit(AppEvents.alertError, [
+ t('time-picker.copy-paste.default-error-title', 'Invalid time range'),
+ t('time-picker.copy-paste.default-error-message', `{{error}} is not a valid time range`, { error }),
+ ])
+ }
/>
);
}}
diff --git a/public/app/core/components/help/HelpModal.tsx b/public/app/core/components/help/HelpModal.tsx
index f13cf217004..45c38d23a6c 100644
--- a/public/app/core/components/help/HelpModal.tsx
+++ b/public/app/core/components/help/HelpModal.tsx
@@ -121,6 +121,14 @@ const getShortcuts = (modKey: string) => {
'Make time range absolute/permanent'
),
},
+ {
+ keys: ['t', 'c'],
+ description: t('help-modal.shortcuts-description.copy-time-range', 'Copy time range'),
+ },
+ {
+ keys: ['t', 'v'],
+ description: t('help-modal.shortcuts-description.paste-time-range', 'Paste time range'),
+ },
],
},
];
diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts
index 2f126c22145..6e6dc593475 100644
--- a/public/app/core/services/keybindingSrv.ts
+++ b/public/app/core/services/keybindingSrv.ts
@@ -18,6 +18,8 @@ import {
ShowModalReactEvent,
ZoomOutEvent,
AbsoluteTimeEvent,
+ CopyTimeEvent,
+ PasteTimeEvent,
} from '../../types/events';
import { AppChromeService } from '../components/AppChrome/AppChromeService';
import { HelpModal } from '../components/help/HelpModal';
@@ -203,6 +205,14 @@ export class KeybindingSrv {
this.bind('t right', () => {
appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Right, updateUrl }));
});
+
+ this.bind('t c', () => {
+ appEvents.publish(new CopyTimeEvent());
+ });
+
+ this.bind('t v', () => {
+ appEvents.publish(new PasteTimeEvent({ updateUrl }));
+ });
}
setupDashboardBindings(dashboard: DashboardModel) {
diff --git a/public/app/core/utils/timePicker.ts b/public/app/core/utils/timePicker.ts
index 08dd76c2531..ba862fb1858 100644
--- a/public/app/core/utils/timePicker.ts
+++ b/public/app/core/utils/timePicker.ts
@@ -1,4 +1,6 @@
-import { TimeRange, toUtc, AbsoluteTimeRange } from '@grafana/data';
+import { TimeRange, toUtc, AbsoluteTimeRange, RawTimeRange } from '@grafana/data';
+
+type CopiedTimeRangeResult = { range: RawTimeRange; isError: false } | { range: string; isError: true };
export const getShiftedTimeRange = (direction: number, origRange: TimeRange): AbsoluteTimeRange => {
const range = {
@@ -38,3 +40,20 @@ export const getZoomedTimeRange = (range: TimeRange, factor: number): AbsoluteTi
return { from, to };
};
+
+export async function getCopiedTimeRange(): Promise {
+ const raw = await navigator.clipboard.readText();
+ let range;
+
+ try {
+ range = JSON.parse(raw);
+
+ if (!range.from || !range.to) {
+ return { range: raw, isError: true };
+ }
+
+ return { range, isError: false };
+ } catch (e) {
+ return { range: raw, isError: true };
+ }
+}
diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts
index ff53e2ca189..8c3d3f7ac84 100644
--- a/public/app/features/dashboard/services/TimeSrv.ts
+++ b/public/app/features/dashboard/services/TimeSrv.ts
@@ -10,16 +10,25 @@ import {
TimeRange,
toUtc,
IntervalValues,
+ AppEvents,
} from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { sceneGraph } from '@grafana/scenes';
+import { t } from '@grafana/ui/src/utils/i18n';
import appEvents from 'app/core/app_events';
import { config } from 'app/core/config';
import { AutoRefreshInterval, contextSrv, ContextSrv } from 'app/core/services/context_srv';
-import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker';
+import { getCopiedTimeRange, getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker';
import { getTimeRange } from 'app/features/dashboard/utils/timeRange';
-import { AbsoluteTimeEvent, ShiftTimeEvent, ShiftTimeEventDirection, ZoomOutEvent } from '../../../types/events';
+import {
+ AbsoluteTimeEvent,
+ CopyTimeEvent,
+ PasteTimeEvent,
+ ShiftTimeEvent,
+ ShiftTimeEventDirection,
+ ZoomOutEvent,
+} from '../../../types/events';
import { TimeModel } from '../state/TimeModel';
import { getRefreshFromUrl } from '../utils/getRefreshFromUrl';
@@ -51,6 +60,14 @@ export class TimeSrv {
this.makeAbsoluteTime(e.payload.updateUrl);
});
+ appEvents.subscribe(CopyTimeEvent, () => {
+ this.copyTimeRangeToClipboard();
+ });
+
+ appEvents.subscribe(PasteTimeEvent, (e) => {
+ this.pasteTimeRangeFromClipboard(e.payload.updateUrl);
+ });
+
document.addEventListener('visibilitychange', () => {
if (this.autoRefreshBlocked && document.visibilityState === 'visible') {
this.autoRefreshBlocked = false;
@@ -357,6 +374,30 @@ export class TimeSrv {
this.setTime({ from, to }, updateUrl);
}
+ copyTimeRangeToClipboard() {
+ const { raw } = this.timeRange();
+ navigator.clipboard.writeText(JSON.stringify({ from: raw.from, to: raw.to }));
+ appEvents.emit(AppEvents.alertSuccess, [
+ t('time-picker.copy-paste.copy-success-message', 'Time range copied to clipboard'),
+ ]);
+ }
+
+ async pasteTimeRangeFromClipboard(updateUrl = true) {
+ const { range, isError } = await getCopiedTimeRange();
+
+ if (isError === true) {
+ appEvents.emit(AppEvents.alertError, [
+ t('time-picker.copy-paste.default-error-title', 'Invalid time range'),
+ t('time-picker.copy-paste.default-error-message', '{{error}} is not a valid time range', { error: range }),
+ ]);
+ return;
+ }
+
+ const { from, to } = range;
+
+ this.setTime({ from, to }, updateUrl);
+ }
+
// isRefreshOutsideThreshold function calculates the difference between last refresh and now
// if the difference is outside 5% of the current set time range then the function will return true
// if the difference is within 5% of the current set time range then the function will return false
diff --git a/public/app/features/explore/hooks/useKeyboardShortcuts.test.tsx b/public/app/features/explore/hooks/useKeyboardShortcuts.test.tsx
index da9d1b9969f..c58336af1c8 100644
--- a/public/app/features/explore/hooks/useKeyboardShortcuts.test.tsx
+++ b/public/app/features/explore/hooks/useKeyboardShortcuts.test.tsx
@@ -1,9 +1,16 @@
-import { render } from '@testing-library/react';
+import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { dateTime, EventBusSrv } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime';
-import { AbsoluteTimeEvent, ShiftTimeEvent, ShiftTimeEventDirection, ZoomOutEvent } from 'app/types/events';
+import {
+ AbsoluteTimeEvent,
+ CopyTimeEvent,
+ PasteTimeEvent,
+ ShiftTimeEvent,
+ ShiftTimeEventDirection,
+ ZoomOutEvent,
+} from 'app/types/events';
import { TestProvider } from '../../../../test/helpers/TestProvider';
import { configureStore } from '../../../store/configureStore';
@@ -22,6 +29,15 @@ jest.mock('@grafana/runtime', () => {
};
});
+const mockClipboard = {
+ writeText: jest.fn(),
+ readText: jest.fn(),
+};
+
+Object.defineProperty(global.navigator, 'clipboard', {
+ value: mockClipboard,
+});
+
const NOW = new Date('2020-10-10T00:00:00.000Z');
function daysFromNow(daysDiff: number) {
return new Date(NOW.getTime() + daysDiff * 86400000);
@@ -111,4 +127,31 @@ describe('useKeyboardShortcuts', () => {
expect(panes[1]!.absoluteRange.from).toBe(daysFromNow(-3).getTime());
expect(panes[1]!.absoluteRange.to).toBe(daysFromNow(1).getTime());
});
+
+ it('copies the time range from the left pane', () => {
+ const store = setup();
+
+ getAppEvents().publish(new CopyTimeEvent());
+
+ const fromValue = store.getState().explore.panes.left!.range.raw.from;
+ const toValue = store.getState().explore.panes.left!.range.raw.to;
+
+ expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith(JSON.stringify({ from: fromValue, to: toValue }));
+ });
+
+ it('pastes the time range to left pane', async () => {
+ const store = setup();
+
+ const fromValue = 'now-3d';
+ const toValue = 'now';
+
+ mockClipboard.readText.mockResolvedValue(JSON.stringify({ from: fromValue, to: toValue }));
+ getAppEvents().publish(new PasteTimeEvent({ updateUrl: false }));
+
+ await waitFor(() => {
+ const raw = store.getState().explore.panes.left!.range.raw;
+ expect(raw.from).toBe(fromValue);
+ expect(raw.to).toBe(toValue);
+ });
+ });
});
diff --git a/public/app/features/explore/hooks/useKeyboardShortcuts.ts b/public/app/features/explore/hooks/useKeyboardShortcuts.ts
index 48c5bc43208..bd90b13e3f2 100644
--- a/public/app/features/explore/hooks/useKeyboardShortcuts.ts
+++ b/public/app/features/explore/hooks/useKeyboardShortcuts.ts
@@ -4,9 +4,15 @@ import { Unsubscribable } from 'rxjs';
import { getAppEvents } from '@grafana/runtime';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { useDispatch } from 'app/types';
-import { AbsoluteTimeEvent, ShiftTimeEvent, ZoomOutEvent } from 'app/types/events';
+import { AbsoluteTimeEvent, CopyTimeEvent, PasteTimeEvent, ShiftTimeEvent, ZoomOutEvent } from 'app/types/events';
-import { makeAbsoluteTime, shiftTime, zoomOut } from '../state/time';
+import {
+ copyTimeRangeToClipboard,
+ makeAbsoluteTime,
+ pasteTimeRangeFromClipboard,
+ shiftTime,
+ zoomOut,
+} from '../state/time';
export function useKeyboardShortcuts() {
const { keybindings } = useGrafana();
@@ -35,6 +41,18 @@ export function useKeyboardShortcuts() {
})
);
+ tearDown.push(
+ getAppEvents().subscribe(CopyTimeEvent, () => {
+ dispatch(copyTimeRangeToClipboard());
+ })
+ );
+
+ tearDown.push(
+ getAppEvents().subscribe(PasteTimeEvent, () => {
+ dispatch(pasteTimeRangeFromClipboard());
+ })
+ );
+
return () => {
tearDown.forEach((u) => u.unsubscribe());
};
diff --git a/public/app/features/explore/state/time.ts b/public/app/features/explore/state/time.ts
index 6284e98064e..38683f45bf4 100644
--- a/public/app/features/explore/state/time.ts
+++ b/public/app/features/explore/state/time.ts
@@ -1,10 +1,19 @@
import { AnyAction, createAction } from '@reduxjs/toolkit';
-import { AbsoluteTimeRange, dateTimeForTimeZone, LoadingState, RawTimeRange, TimeRange } from '@grafana/data';
+import {
+ AbsoluteTimeRange,
+ AppEvents,
+ dateTimeForTimeZone,
+ LoadingState,
+ RawTimeRange,
+ TimeRange,
+} from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { RefreshPicker } from '@grafana/ui';
+import { t } from '@grafana/ui/src/utils/i18n';
+import appEvents from 'app/core/app_events';
import { getTimeRange, refreshIntervalToSortOrder, stopQueryState } from 'app/core/utils/explore';
-import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker';
+import { getCopiedTimeRange, getShiftedTimeRange, getZoomedTimeRange } 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';
@@ -166,6 +175,41 @@ 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));
+
+ appEvents.emit(AppEvents.alertSuccess, [
+ t('time-picker.copy-paste.copy-success-message', 'Time range copied to clipboard'),
+ ]);
+ };
+}
+
+export function pasteTimeRangeFromClipboard(): ThunkResult {
+ return async (dispatch, getState) => {
+ const { range, isError } = await getCopiedTimeRange();
+
+ if (isError === true) {
+ appEvents.emit(AppEvents.alertError, [
+ t('time-picker.copy-paste.default-error-title', 'Invalid time range'),
+ t('time-picker.copy-paste.default-error-message', `{{error}} is not a valid time range`, { error: range }),
+ ]);
+ return;
+ }
+
+ 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 }));
+ return;
+ }
+
+ dispatch(updateTimeRange({ exploreId: Object.keys(getState().explore.panes)[0], rawRange: range }));
+ };
+}
+
/**
* Reducer for an Explore area, to be used by the global Explore reducer.
*/
diff --git a/public/app/types/events.ts b/public/app/types/events.ts
index 6f3e86e5134..9d742e99e8a 100644
--- a/public/app/types/events.ts
+++ b/public/app/types/events.ts
@@ -160,6 +160,18 @@ export class ShiftTimeEvent extends BusEventWithPayload {
static type = 'shift-time';
}
+export class CopyTimeEvent extends BusEventBase {
+ static type = 'copy-time';
+}
+
+interface PasteTimeEventPayload {
+ updateUrl?: boolean;
+}
+
+export class PasteTimeEvent extends BusEventWithPayload {
+ static type = 'paste-time';
+}
+
interface AbsoluteTimeEventPayload {
updateUrl: boolean;
}
diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json
index 8b8d1774af3..9bd9ccb9be1 100644
--- a/public/locales/de-DE/grafana.json
+++ b/public/locales/de-DE/grafana.json
@@ -586,6 +586,7 @@
"shortcuts-description": {
"change-theme": "Thema ändern",
"collapse-all-rows": "Alle Zeilen einklappen",
+ "copy-time-range": "",
"dashboard-settings": "Dashboard-Einstellungen",
"duplicate-panel": "Fenster duplizieren",
"exit-edit/setting-views": "Ansicht beenden/einstellen",
@@ -599,6 +600,7 @@
"move-time-range-forward": "Zeitbereich nach vorne verschieben",
"open-search": "Suche öffnen",
"open-shared-modal": "Geteilter Fenstermodus öffnen",
+ "paste-time-range": "",
"refresh-all-panels": "Alle Fenster aktualisieren",
"remove-panel": "Fenster entfernen",
"save-dashboard": "Dashboard speichern",
@@ -1394,6 +1396,13 @@
"empty-recent-list-info": "Es sieht so aus, als hätten Sie diesen Zeit-Auswähler noch nie benutzt. Sobald Sie einige Zeitintervalle eingeben, werden hier die zuletzt verwendeten Intervalle angezeigt.",
"filter-placeholder": "Schnellbereiche suchen"
},
+ "copy-paste": {
+ "copy-success-message": "",
+ "default-error-message": "",
+ "default-error-title": "",
+ "tooltip-copy": "",
+ "tooltip-paste": ""
+ },
"footer": {
"change-settings-button": "Zeiteinstellungen ändern",
"fiscal-year-option": "Geschäftsjahr",
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index b4263184b9d..24ad3f6f952 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -586,6 +586,7 @@
"shortcuts-description": {
"change-theme": "Change theme",
"collapse-all-rows": "Collapse all rows",
+ "copy-time-range": "Copy time range",
"dashboard-settings": "Dashboard settings",
"duplicate-panel": "Duplicate Panel",
"exit-edit/setting-views": "Exit edit/setting views",
@@ -599,6 +600,7 @@
"move-time-range-forward": "Move time range forward",
"open-search": "Open search",
"open-shared-modal": "Open Panel Share Modal",
+ "paste-time-range": "Paste time range",
"refresh-all-panels": "Refresh all panels",
"remove-panel": "Remove Panel",
"save-dashboard": "Save dashboard",
@@ -1394,6 +1396,13 @@
"empty-recent-list-info": "It looks like you haven't used this time picker before. As soon as you enter some time intervals, recently used intervals will appear here.",
"filter-placeholder": "Search quick ranges"
},
+ "copy-paste": {
+ "copy-success-message": "Time range copied to clipboard",
+ "default-error-message": "{{error}} is not a valid time range",
+ "default-error-title": "Invalid time range",
+ "tooltip-copy": "Copy time range to clipboard",
+ "tooltip-paste": "Paste time range"
+ },
"footer": {
"change-settings-button": "Change time settings",
"fiscal-year-option": "Fiscal year",
diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json
index c74fcd3eebb..467288d40f2 100644
--- a/public/locales/es-ES/grafana.json
+++ b/public/locales/es-ES/grafana.json
@@ -591,6 +591,7 @@
"shortcuts-description": {
"change-theme": "Cambiar tema",
"collapse-all-rows": "Contraer todas las filas",
+ "copy-time-range": "",
"dashboard-settings": "Ajustes del panel de control",
"duplicate-panel": "Duplicar panel",
"exit-edit/setting-views": "Salir de las opciones de edición/ajustes",
@@ -604,6 +605,7 @@
"move-time-range-forward": "Mover el rango de tiempo hacia delante",
"open-search": "Abrir búsqueda",
"open-shared-modal": "Abrir el modo de panel compartido",
+ "paste-time-range": "",
"refresh-all-panels": "Actualizar todos los paneles",
"remove-panel": "Eliminar panel",
"save-dashboard": "Guardar panel de control",
@@ -1400,6 +1402,13 @@
"empty-recent-list-info": "Parece que no ha utilizado antes este selector de tiempo. En cuanto introduzca algún intervalo de tiempo, los intervalos que haya usado recientemente aparecerán aquí.",
"filter-placeholder": "Buscar intervalos rápidos"
},
+ "copy-paste": {
+ "copy-success-message": "",
+ "default-error-message": "",
+ "default-error-title": "",
+ "tooltip-copy": "",
+ "tooltip-paste": ""
+ },
"footer": {
"change-settings-button": "Cambiar ajustes de tiempo",
"fiscal-year-option": "Ejercicio fiscal",
diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json
index 0299020d77f..7d3e5ff837e 100644
--- a/public/locales/fr-FR/grafana.json
+++ b/public/locales/fr-FR/grafana.json
@@ -591,6 +591,7 @@
"shortcuts-description": {
"change-theme": "Modifier le thème",
"collapse-all-rows": "Réduire toutes les lignes",
+ "copy-time-range": "",
"dashboard-settings": "Paramètres du tableau de bord",
"duplicate-panel": "Dupliquer le panneau",
"exit-edit/setting-views": "Quitter les affichages de modification/configuration",
@@ -604,6 +605,7 @@
"move-time-range-forward": "Avancer la plage de temps",
"open-search": "Ouvrir la recherche",
"open-shared-modal": "Ouvrir le panneau de partage modal",
+ "paste-time-range": "",
"refresh-all-panels": "Actualiser tous les panneaux",
"remove-panel": "Retirer le panneau",
"save-dashboard": "Enregistrer le tableau de bord",
@@ -1400,6 +1402,13 @@
"empty-recent-list-info": "Il semble que vous n'ayez jamais utilisé ce sélecteur de temps dans le passé. Lorsque vous commencerez à utiliser des plages de temps, celles récemment utilisées apparaîtront ici.",
"filter-placeholder": "Rechercher dans les plages rapides"
},
+ "copy-paste": {
+ "copy-success-message": "",
+ "default-error-message": "",
+ "default-error-title": "",
+ "tooltip-copy": "",
+ "tooltip-paste": ""
+ },
"footer": {
"change-settings-button": "Modifier les paramètres de l'heure",
"fiscal-year-option": "Exercice fiscal",
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index 527664d55ba..afd1b579502 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -586,6 +586,7 @@
"shortcuts-description": {
"change-theme": "Cĥäʼnģę ŧĥęmę",
"collapse-all-rows": "Cőľľäpşę äľľ řőŵş",
+ "copy-time-range": "Cőpy ŧįmę řäʼnģę",
"dashboard-settings": "Đäşĥþőäřđ şęŧŧįʼnģş",
"duplicate-panel": "Đūpľįčäŧę Päʼnęľ",
"exit-edit/setting-views": "Ēχįŧ ęđįŧ/şęŧŧįʼnģ vįęŵş",
@@ -599,6 +600,7 @@
"move-time-range-forward": "Mővę ŧįmę řäʼnģę ƒőřŵäřđ",
"open-search": "Øpęʼn şęäřčĥ",
"open-shared-modal": "Øpęʼn Päʼnęľ Ŝĥäřę Mőđäľ",
+ "paste-time-range": "Päşŧę ŧįmę řäʼnģę",
"refresh-all-panels": "Ŗęƒřęşĥ äľľ päʼnęľş",
"remove-panel": "Ŗęmővę Päʼnęľ",
"save-dashboard": "Ŝävę đäşĥþőäřđ",
@@ -1394,6 +1396,13 @@
"empty-recent-list-info": "Ĩŧ ľőőĸş ľįĸę yőū ĥävęʼn'ŧ ūşęđ ŧĥįş ŧįmę pįčĸęř þęƒőřę. Åş şőőʼn äş yőū ęʼnŧęř şőmę ŧįmę įʼnŧęřväľş, řęčęʼnŧľy ūşęđ įʼnŧęřväľş ŵįľľ äppęäř ĥęřę.",
"filter-placeholder": "Ŝęäřčĥ qūįčĸ řäʼnģęş"
},
+ "copy-paste": {
+ "copy-success-message": "Ŧįmę řäʼnģę čőpįęđ ŧő čľįpþőäřđ",
+ "default-error-message": "{{error}} įş ʼnőŧ ä väľįđ ŧįmę řäʼnģę",
+ "default-error-title": "Ĩʼnväľįđ ŧįmę řäʼnģę",
+ "tooltip-copy": "Cőpy ŧįmę řäʼnģę ŧő čľįpþőäřđ",
+ "tooltip-paste": "Päşŧę ŧįmę řäʼnģę"
+ },
"footer": {
"change-settings-button": "Cĥäʼnģę ŧįmę şęŧŧįʼnģş",
"fiscal-year-option": "Fįşčäľ yęäř",
diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json
index 13fef03e0cc..aec40e87453 100644
--- a/public/locales/zh-Hans/grafana.json
+++ b/public/locales/zh-Hans/grafana.json
@@ -581,6 +581,7 @@
"shortcuts-description": {
"change-theme": "更改主题",
"collapse-all-rows": "折叠所有行",
+ "copy-time-range": "",
"dashboard-settings": "仪表板设置",
"duplicate-panel": "复制面板",
"exit-edit/setting-views": "退出编辑/设置视图",
@@ -594,6 +595,7 @@
"move-time-range-forward": "向前移动时间范围",
"open-search": "打开搜索",
"open-shared-modal": "打开面板分享模式",
+ "paste-time-range": "",
"refresh-all-panels": "刷新所有面板",
"remove-panel": "删除面板",
"save-dashboard": "保存仪表板",
@@ -1388,6 +1390,13 @@
"empty-recent-list-info": "看起来您之前没有使用过这个时间选择器。一旦您输入了某些时间间隔,最近使用的间隔就会出现在此处。",
"filter-placeholder": "搜索快速范围"
},
+ "copy-paste": {
+ "copy-success-message": "",
+ "default-error-message": "",
+ "default-error-title": "",
+ "tooltip-copy": "",
+ "tooltip-paste": ""
+ },
"footer": {
"change-settings-button": "更改时间设置",
"fiscal-year-option": "财政年度",