mirror of
https://github.com/grafana/grafana.git
synced 2024-11-28 19:54:10 -06:00
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
This commit is contained in:
parent
e8e8017d51
commit
f285eb6717
@ -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',
|
||||
|
@ -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}
|
||||
/>
|
||||
</section>
|
||||
</FocusScope>
|
||||
|
@ -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 && (
|
||||
<div className={styles.body} id="expanded-timerange">
|
||||
<div className={styles.form}>
|
||||
<TimeRangeContent value={value} onApply={onChange} timeZone={timeZone} isFullscreen={false} />
|
||||
<TimeRangeContent
|
||||
value={value}
|
||||
onApply={onChange}
|
||||
timeZone={timeZone}
|
||||
isFullscreen={false}
|
||||
onError={onError}
|
||||
/>
|
||||
</div>
|
||||
{showHistory && (
|
||||
<TimeRangeList
|
||||
@ -173,7 +180,7 @@ const NarrowScreenForm = (props: FormProps) => {
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
{props.showHistory && (
|
||||
|
@ -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(
|
||||
<TimeRangeContent isFullscreen={true} value={initial} onApply={() => {}} 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 = {
|
||||
|
@ -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) => {
|
||||
</Field>
|
||||
{fyTooltip}
|
||||
</div>
|
||||
<Button data-testid={selectors.components.TimePicker.applyTimeRange} type="button" onClick={onApply}>
|
||||
<Trans i18nKey="time-picker.range-content.apply-button">Apply time range</Trans>
|
||||
</Button>
|
||||
<div className={style.buttonsContainer}>
|
||||
<Button
|
||||
data-testid={selectors.components.TimePicker.copyTimeRange}
|
||||
icon="copy"
|
||||
variant="secondary"
|
||||
tooltip={t('time-picker.copy-paste.tooltip-copy', 'Copy time range to clipboard')}
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
/>
|
||||
<Button
|
||||
data-testid={selectors.components.TimePicker.pasteTimeRange}
|
||||
icon="clipboard-alt"
|
||||
variant="secondary"
|
||||
tooltip={t('time-picker.copy-paste.tooltip-paste', 'Paste time range')}
|
||||
type="button"
|
||||
onClick={onPaste}
|
||||
/>
|
||||
<Button data-testid={selectors.components.TimePicker.applyTimeRange} type="button" onClick={onApply}>
|
||||
<Trans i18nKey="time-picker.range-content.apply-button">Apply time range</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TimePickerCalendar
|
||||
isFullscreen={isFullscreen}
|
||||
@ -220,6 +270,11 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
fieldContainer: css({
|
||||
display: 'flex',
|
||||
}),
|
||||
buttonsContainer: css({
|
||||
display: 'flex',
|
||||
gap: theme.spacing(0.5),
|
||||
marginTop: theme.spacing(1),
|
||||
}),
|
||||
tooltip: css({
|
||||
paddingLeft: theme.spacing(1),
|
||||
paddingTop: theme.spacing(3),
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { uniqBy } from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
import { TimeRange, isDateTime, rangeUtil } from '@grafana/data';
|
||||
import { AppEvents, TimeRange, isDateTime, rangeUtil } from '@grafana/data';
|
||||
import { TimeRangePickerProps, TimeRangePicker } from '@grafana/ui';
|
||||
import { t } from '@grafana/ui/src/utils/i18n';
|
||||
import appEvents from 'app/core/app_events';
|
||||
|
||||
import { LocalStorageValueProvider } from '../LocalStorageValueProvider';
|
||||
|
||||
@ -34,6 +36,12 @@ export const TimePickerWithHistory = (props: Props) => {
|
||||
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 }),
|
||||
])
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
@ -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) {
|
||||
|
@ -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<CopiedTimeRangeResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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());
|
||||
};
|
||||
|
@ -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<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));
|
||||
|
||||
appEvents.emit(AppEvents.alertSuccess, [
|
||||
t('time-picker.copy-paste.copy-success-message', 'Time range copied to clipboard'),
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
export function pasteTimeRangeFromClipboard(): ThunkResult<void> {
|
||||
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.
|
||||
*/
|
||||
|
@ -160,6 +160,18 @@ export class ShiftTimeEvent extends BusEventWithPayload<ShiftTimeEventPayload> {
|
||||
static type = 'shift-time';
|
||||
}
|
||||
|
||||
export class CopyTimeEvent extends BusEventBase {
|
||||
static type = 'copy-time';
|
||||
}
|
||||
|
||||
interface PasteTimeEventPayload {
|
||||
updateUrl?: boolean;
|
||||
}
|
||||
|
||||
export class PasteTimeEvent extends BusEventWithPayload<PasteTimeEventPayload> {
|
||||
static type = 'paste-time';
|
||||
}
|
||||
|
||||
interface AbsoluteTimeEventPayload {
|
||||
updateUrl: boolean;
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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ęäř",
|
||||
|
@ -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": "财政年度",
|
||||
|
Loading…
Reference in New Issue
Block a user