Explore: Add t * keybindings to change time range (#45020)

* Add keybindings to explore, allow override of dashboard model update for explore update

* Remove edits to Dashboard Model, add definition when url params should be updated

* Add tests

* Add and expose util function instead of bringing in unrelated library do not define explicit path to library file

* Use more generic model for TimeSrv

* Remove url utility functions, use javascript function instead

* Break out TimeModel into new type and bring it in

* condense object creation
This commit is contained in:
Kristina 2022-02-17 07:39:02 -06:00 committed by GitHub
parent af1691dbfb
commit 773da0e330
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 140 additions and 79 deletions

View File

@ -19,6 +19,28 @@ e2e.scenario({
cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click(); cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click();
cy.location().then((loc) => {
const params = new URLSearchParams(loc.search);
const leftJSON = JSON.parse(params.get('left'));
expect(leftJSON.range.to).to.equal('now');
expect(leftJSON.range.from).to.equal('now-1h');
cy.get('body').click();
cy.get('body').type('t{leftarrow}');
cy.location().then((locPostKeypress) => {
const params = new URLSearchParams(locPostKeypress.search);
const leftJSON = JSON.parse(params.get('left'));
// be sure the keypress affected the time window
expect(leftJSON.range.to).to.not.equal('now');
expect(leftJSON.range.from).to.not.equal('now-1h');
// be sure the url does not contain dashboard range values
// eslint wants this to be a function, so we use this instead of to.be.false
expect(params.has('to')).to.equal(false);
expect(params.has('from')).to.equal(false);
});
});
const canvases = e2e().get('canvas'); const canvases = e2e().get('canvas');
canvases.should('have.length', 1); canvases.should('have.length', 1);
}, },

View File

@ -11,7 +11,7 @@ import { exitKioskMode, toggleKioskMode } from '../navigation/kiosk';
import { import {
RemovePanelEvent, RemovePanelEvent,
ShiftTimeEvent, ShiftTimeEvent,
ShiftTimeEventPayload, ShiftTimeEventDirection,
ShowModalReactEvent, ShowModalReactEvent,
ZoomOutEvent, ZoomOutEvent,
AbsoluteTimeEvent, AbsoluteTimeEvent,
@ -171,6 +171,24 @@ export class KeybindingSrv {
this.bind(keyArg, withFocusedPanel(fn)); this.bind(keyArg, withFocusedPanel(fn));
} }
setupTimeRangeBindings(updateUrl = true) {
this.bind('t z', () => {
appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl }));
});
this.bind('ctrl+z', () => {
appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl }));
});
this.bind('t left', () => {
appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Left, updateUrl }));
});
this.bind('t right', () => {
appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Right, updateUrl }));
});
}
setupDashboardBindings(dashboard: DashboardModel) { setupDashboardBindings(dashboard: DashboardModel) {
this.bind('mod+o', () => { this.bind('mod+o', () => {
dashboard.graphTooltip = (dashboard.graphTooltip + 1) % 3; dashboard.graphTooltip = (dashboard.graphTooltip + 1) % 3;
@ -191,21 +209,7 @@ export class KeybindingSrv {
} }
}); });
this.bind('t z', () => { this.setupTimeRangeBindings();
appEvents.publish(new ZoomOutEvent(2));
});
this.bind('ctrl+z', () => {
appEvents.publish(new ZoomOutEvent(2));
});
this.bind('t left', () => {
appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Left));
});
this.bind('t right', () => {
appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Right));
});
// edit panel // edit panel
this.bindWithPanelId('e', (panelId) => { this.bindWithPanelId('e', (panelId) => {

View File

@ -13,7 +13,7 @@ import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePicker
// Utils & Services // Utils & Services
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { appEvents } from 'app/core/core'; import { appEvents } from 'app/core/core';
import { ShiftTimeEvent, ShiftTimeEventPayload, ZoomOutEvent } from '../../../../types/events'; import { ShiftTimeEvent, ShiftTimeEventDirection, ZoomOutEvent } from '../../../../types/events';
import { Unsubscribable } from 'rxjs'; import { Unsubscribable } from 'rxjs';
export interface Props { export interface Props {
@ -38,16 +38,16 @@ export class DashNavTimeControls extends Component<Props> {
}; };
onRefresh = () => { onRefresh = () => {
getTimeSrv().refreshDashboard(); getTimeSrv().refreshTimeModel();
return Promise.resolve(); return Promise.resolve();
}; };
onMoveBack = () => { onMoveBack = () => {
appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Left)); appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Left }));
}; };
onMoveForward = () => { onMoveForward = () => {
appEvents.publish(new ShiftTimeEvent(ShiftTimeEventPayload.Right)); appEvents.publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Right }));
}; };
onChangeTimePicker = (timeRange: TimeRange) => { onChangeTimePicker = (timeRange: TimeRange) => {
@ -77,7 +77,7 @@ export class DashNavTimeControls extends Component<Props> {
}; };
onZoom = () => { onZoom = () => {
appEvents.publish(new ZoomOutEvent(2)); appEvents.publish(new ZoomOutEvent({ scale: 2 }));
}; };
render() { render() {

View File

@ -244,6 +244,11 @@ describe('timeSrv', () => {
expect(locationUpdates[1].search).toEqual('?kiosk&from=now-1h&to=now-10s'); expect(locationUpdates[1].search).toEqual('?kiosk&from=now-1h&to=now-10s');
}); });
it('should not change the URL if the updateUrl param is false', () => {
timeSrv.setTime({ from: '1644340584281', to: '1644340584281' }, false);
expect(locationUpdates.length).toBe(0);
});
}); });
describe('pauseAutoRefresh', () => { describe('pauseAutoRefresh', () => {

View File

@ -9,14 +9,14 @@ import {
TimeRange, TimeRange,
toUtc, toUtc,
} from '@grafana/data'; } from '@grafana/data';
import { DashboardModel } from '../state/DashboardModel';
import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker'; import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker';
import { config } from 'app/core/config'; import { config } from 'app/core/config';
import { getRefreshFromUrl } from '../utils/getRefreshFromUrl'; import { getRefreshFromUrl } from '../utils/getRefreshFromUrl';
import { locationService } from '@grafana/runtime'; import { locationService } from '@grafana/runtime';
import { AbsoluteTimeEvent, ShiftTimeEvent, ShiftTimeEventPayload, ZoomOutEvent } from '../../../types/events'; import { AbsoluteTimeEvent, ShiftTimeEvent, ShiftTimeEventDirection, ZoomOutEvent } from '../../../types/events';
import { contextSrv, ContextSrv } from 'app/core/services/context_srv'; import { contextSrv, ContextSrv } from 'app/core/services/context_srv';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { TimeModel } from '../state/TimeModel';
export class TimeSrv { export class TimeSrv {
time: any; time: any;
@ -24,21 +24,21 @@ export class TimeSrv {
refresh: any; refresh: any;
previousAutoRefresh: any; previousAutoRefresh: any;
oldRefresh: string | null | undefined; oldRefresh: string | null | undefined;
dashboard?: DashboardModel; timeModel?: TimeModel;
timeAtLoad: any; timeAtLoad: any;
private autoRefreshBlocked?: boolean; private autoRefreshBlocked?: boolean;
constructor(private contextSrv: ContextSrv) { constructor(private contextSrv: ContextSrv) {
// default time // default time
this.time = getDefaultTimeRange().raw; this.time = getDefaultTimeRange().raw;
this.refreshDashboard = this.refreshDashboard.bind(this); this.refreshTimeModel = this.refreshTimeModel.bind(this);
appEvents.subscribe(ZoomOutEvent, (e) => { appEvents.subscribe(ZoomOutEvent, (e) => {
this.zoomOut(e.payload); this.zoomOut(e.payload.scale, e.payload.updateUrl);
}); });
appEvents.subscribe(ShiftTimeEvent, (e) => { appEvents.subscribe(ShiftTimeEvent, (e) => {
this.shiftTime(e.payload); this.shiftTime(e.payload.direction, e.payload.updateUrl);
}); });
appEvents.subscribe(AbsoluteTimeEvent, () => { appEvents.subscribe(AbsoluteTimeEvent, () => {
@ -48,15 +48,15 @@ export class TimeSrv {
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (this.autoRefreshBlocked && document.visibilityState === 'visible') { if (this.autoRefreshBlocked && document.visibilityState === 'visible') {
this.autoRefreshBlocked = false; this.autoRefreshBlocked = false;
this.refreshDashboard(); this.refreshTimeModel();
} }
}); });
} }
init(dashboard: DashboardModel) { init(timeModel: TimeModel) {
this.dashboard = dashboard; this.timeModel = timeModel;
this.time = dashboard.time; this.time = timeModel.time;
this.refresh = dashboard.refresh; this.refresh = timeModel.refresh;
this.initTimeFromUrl(); this.initTimeFromUrl();
this.parseTime(); this.parseTime();
@ -66,8 +66,8 @@ export class TimeSrv {
const range = rangeUtil.convertRawToRange( const range = rangeUtil.convertRawToRange(
this.time, this.time,
this.dashboard?.getTimezone(), this.timeModel?.getTimezone(),
this.dashboard?.fiscalYearStartMonth this.timeModel?.fiscalYearStartMonth
); );
if (range.to.isBefore(range.from)) { if (range.to.isBefore(range.from)) {
@ -159,11 +159,11 @@ export class TimeSrv {
this.time.to = this.parseUrlParam(params.get('to')!) || this.time.to; this.time.to = this.parseUrlParam(params.get('to')!) || this.time.to;
} }
// if absolute ignore refresh option saved to dashboard // if absolute ignore refresh option saved to timeModel
if (params.get('to') && params.get('to')!.indexOf('now') === -1) { if (params.get('to') && params.get('to')!.indexOf('now') === -1) {
this.refresh = false; this.refresh = false;
if (this.dashboard) { if (this.timeModel) {
this.dashboard.refresh = false; this.timeModel.refresh = false;
} }
} }
@ -176,8 +176,8 @@ export class TimeSrv {
this.refresh = getRefreshFromUrl({ this.refresh = getRefreshFromUrl({
params: paramsJSON, params: paramsJSON,
currentRefresh: this.refresh, currentRefresh: this.refresh,
refreshIntervals: Array.isArray(this.dashboard?.timepicker?.refresh_intervals) refreshIntervals: Array.isArray(this.timeModel?.timepicker?.refresh_intervals)
? this.dashboard?.timepicker?.refresh_intervals ? this.timeModel?.timepicker?.refresh_intervals
: undefined, : undefined,
isAllowedIntervalFn: this.contextSrv.isAllowedInterval, isAllowedIntervalFn: this.contextSrv.isAllowedInterval,
minRefreshInterval: config.minRefreshInterval, minRefreshInterval: config.minRefreshInterval,
@ -213,8 +213,8 @@ export class TimeSrv {
} }
setAutoRefresh(interval: any) { setAutoRefresh(interval: any) {
if (this.dashboard) { if (this.timeModel) {
this.dashboard.refresh = interval; this.timeModel.refresh = interval;
} }
this.stopAutoRefresh(); this.stopAutoRefresh();
@ -235,7 +235,7 @@ export class TimeSrv {
this.refreshTimer = setTimeout(() => { this.refreshTimer = setTimeout(() => {
this.startNextRefreshTimer(intervalMs); this.startNextRefreshTimer(intervalMs);
this.refreshDashboard(); this.refreshTimeModel();
}, intervalMs); }, intervalMs);
const refresh = this.contextSrv.getValidInterval(interval); const refresh = this.contextSrv.getValidInterval(interval);
@ -245,15 +245,15 @@ export class TimeSrv {
} }
} }
refreshDashboard() { refreshTimeModel() {
this.dashboard?.timeRangeUpdated(this.timeRange()); this.timeModel?.timeRangeUpdated(this.timeRange());
} }
private startNextRefreshTimer(afterMs: number) { private startNextRefreshTimer(afterMs: number) {
this.refreshTimer = setTimeout(() => { this.refreshTimer = setTimeout(() => {
this.startNextRefreshTimer(afterMs); this.startNextRefreshTimer(afterMs);
if (this.contextSrv.isGrafanaVisible()) { if (this.contextSrv.isGrafanaVisible()) {
this.refreshDashboard(); this.refreshTimeModel();
} else { } else {
this.autoRefreshBlocked = true; this.autoRefreshBlocked = true;
} }
@ -264,10 +264,10 @@ export class TimeSrv {
clearTimeout(this.refreshTimer); clearTimeout(this.refreshTimer);
} }
// store dashboard refresh value and pause auto-refresh in some places // store timeModel refresh value and pause auto-refresh in some places
// i.e panel edit // i.e panel edit
pauseAutoRefresh() { pauseAutoRefresh() {
this.previousAutoRefresh = this.dashboard?.refresh; this.previousAutoRefresh = this.timeModel?.refresh;
this.setAutoRefresh(''); this.setAutoRefresh('');
} }
@ -276,20 +276,19 @@ export class TimeSrv {
this.setAutoRefresh(this.previousAutoRefresh); this.setAutoRefresh(this.previousAutoRefresh);
} }
setTime(time: RawTimeRange, fromRouteUpdate?: boolean) { setTime(time: RawTimeRange, updateUrl = true) {
extend(this.time, time); extend(this.time, time);
// disable refresh if zoom in or zoom out // disable refresh if zoom in or zoom out
if (isDateTime(time.to)) { if (isDateTime(time.to)) {
this.oldRefresh = this.dashboard?.refresh || this.oldRefresh; this.oldRefresh = this.timeModel?.refresh || this.oldRefresh;
this.setAutoRefresh(false); this.setAutoRefresh(false);
} else if (this.oldRefresh && this.oldRefresh !== this.dashboard?.refresh) { } else if (this.oldRefresh && this.oldRefresh !== this.timeModel?.refresh) {
this.setAutoRefresh(this.oldRefresh); this.setAutoRefresh(this.oldRefresh);
this.oldRefresh = null; this.oldRefresh = null;
} }
// update url if (updateUrl === true) {
if (fromRouteUpdate !== true) {
const urlRange = this.timeRangeForUrl(); const urlRange = this.timeRangeForUrl();
const urlParams = locationService.getSearchObject(); const urlParams = locationService.getSearchObject();
@ -303,7 +302,7 @@ export class TimeSrv {
locationService.partial(urlParams); locationService.partial(urlParams);
} }
this.refreshDashboard(); this.refreshTimeModel();
} }
timeRangeForUrl = () => { timeRangeForUrl = () => {
@ -326,30 +325,33 @@ export class TimeSrv {
to: isDateTime(this.time.to) ? dateTime(this.time.to) : this.time.to, to: isDateTime(this.time.to) ? dateTime(this.time.to) : this.time.to,
}; };
const timezone = this.dashboard ? this.dashboard.getTimezone() : undefined; const timezone = this.timeModel ? this.timeModel.getTimezone() : undefined;
return { return {
from: dateMath.parse(raw.from, false, timezone, this.dashboard?.fiscalYearStartMonth)!, from: dateMath.parse(raw.from, false, timezone, this.timeModel?.fiscalYearStartMonth)!,
to: dateMath.parse(raw.to, true, timezone, this.dashboard?.fiscalYearStartMonth)!, to: dateMath.parse(raw.to, true, timezone, this.timeModel?.fiscalYearStartMonth)!,
raw: raw, raw: raw,
}; };
} }
zoomOut(factor: number) { zoomOut(factor: number, updateUrl = true) {
const range = this.timeRange(); const range = this.timeRange();
const { from, to } = getZoomedTimeRange(range, factor); const { from, to } = getZoomedTimeRange(range, factor);
this.setTime({ from: toUtc(from), to: toUtc(to) }); this.setTime({ from: toUtc(from), to: toUtc(to) }, updateUrl);
} }
shiftTime(direction: ShiftTimeEventPayload) { shiftTime(direction: ShiftTimeEventDirection, updateUrl = true) {
const range = this.timeRange(); const range = this.timeRange();
const { from, to } = getShiftedTimeRange(direction, range); const { from, to } = getShiftedTimeRange(direction, range);
this.setTime({ this.setTime(
from: toUtc(from), {
to: toUtc(to), from: toUtc(from),
}); to: toUtc(to),
},
updateUrl
);
} }
makeAbsoluteTime() { makeAbsoluteTime() {
@ -359,7 +361,7 @@ export class TimeSrv {
} }
const { from, to } = this.timeRange(); const { from, to } = this.timeRange();
this.setTime({ from, to }); this.setTime({ from, to }, true);
} }
// isRefreshOutsideThreshold function calculates the difference between last refresh and now // isRefreshOutsideThreshold function calculates the difference between last refresh and now

View File

@ -20,6 +20,7 @@ import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT, REPEAT_DIR_VERT
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
// Types // Types
import { GridPos, PanelModel } from './PanelModel'; import { GridPos, PanelModel } from './PanelModel';
import { TimeModel } from './TimeModel';
import { DashboardMigrator } from './DashboardMigrator'; import { DashboardMigrator } from './DashboardMigrator';
import { import {
AnnotationQuery, AnnotationQuery,
@ -78,7 +79,7 @@ export interface DashboardLink {
includeVars: boolean; includeVars: boolean;
} }
export class DashboardModel { export class DashboardModel implements TimeModel {
id: any; id: any;
uid: string; uid: string;
title: string; title: string;

View File

@ -0,0 +1,10 @@
import { TimeRange, TimeZone } from '@grafana/data';
export interface TimeModel {
time: any;
fiscalYearStartMonth?: number;
refresh: any;
timepicker: any;
getTimezone(): TimeZone;
timeRangeUpdated(timeRange: TimeRange): void;
}

View File

@ -131,12 +131,12 @@ export const updateTimeZoneDashboard =
(timeZone: TimeZone): ThunkResult<void> => (timeZone: TimeZone): ThunkResult<void> =>
(dispatch) => { (dispatch) => {
dispatch(updateTimeZoneForSession(timeZone)); dispatch(updateTimeZoneForSession(timeZone));
getTimeSrv().refreshDashboard(); getTimeSrv().refreshTimeModel();
}; };
export const updateWeekStartDashboard = export const updateWeekStartDashboard =
(weekStart: string): ThunkResult<void> => (weekStart: string): ThunkResult<void> =>
(dispatch) => { (dispatch) => {
dispatch(updateWeekStartForSession(weekStart)); dispatch(updateWeekStartForSession(weekStart));
getTimeSrv().refreshDashboard(); getTimeSrv().refreshTimeModel();
}; };

View File

@ -38,6 +38,7 @@ import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state
import { getDataSourceSrv } from '@grafana/runtime'; import { getDataSourceSrv } from '@grafana/runtime';
import { getRichHistory } from '../../../core/utils/richHistory'; import { getRichHistory } from '../../../core/utils/richHistory';
import { richHistoryUpdatedAction, stateSave } from './main'; import { richHistoryUpdatedAction, stateSave } from './main';
import { keybindingSrv } from 'app/core/services/keybindingSrv';
// //
// Actions and Payloads // Actions and Payloads
@ -172,6 +173,8 @@ export function initializeExplore(
} }
dispatch(updateTime({ exploreId })); dispatch(updateTime({ exploreId }));
keybindingSrv.setupTimeRangeBindings(false);
if (instance) { if (instance) {
// We do not want to add the url to browser history on init because when the pane is initialised it's because // We do not want to add the url to browser history on init because when the pane is initialised it's because
// we already have something in the url. Adding basically the same state as additional history item prevents // we already have something in the url. Adding basically the same state as additional history item prevents

View File

@ -14,7 +14,7 @@ import { ExploreItemState, ThunkResult } from 'app/types';
import { ExploreId } from 'app/types/explore'; import { ExploreId } from 'app/types/explore';
import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors'; import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors';
import { getTimeSrv } from '../../dashboard/services/TimeSrv'; import { getTimeSrv } from '../../dashboard/services/TimeSrv';
import { DashboardModel } from 'app/features/dashboard/state'; import { TimeModel } from '../../dashboard/state/TimeModel';
import { runQueries } from './query'; import { runQueries } from './query';
import { syncTimesAction, stateSave } from './main'; import { syncTimesAction, stateSave } from './main';
@ -95,14 +95,17 @@ export const updateTime = (config: {
const range = getTimeRange(timeZone, rawRange, fiscalYearStartMonth); const range = getTimeRange(timeZone, rawRange, fiscalYearStartMonth);
const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() }; const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
const timeModel: TimeModel = {
time: range.raw,
refresh: false,
timepicker: {},
getTimezone: () => timeZone,
timeRangeUpdated: (rawTimeRange: RawTimeRange) => {
dispatch(updateTimeRange({ exploreId: exploreId, rawRange: rawTimeRange }));
},
};
getTimeSrv().init( getTimeSrv().init(timeModel);
new DashboardModel({
time: range.raw,
refresh: false,
timeZone,
})
);
dispatch(changeRangeAction({ exploreId, range, absoluteRange })); dispatch(changeRangeAction({ exploreId, range, absoluteRange }));
}; };

View File

@ -187,7 +187,7 @@ export class GraphCtrl extends MetricsPanelCtrl {
} }
zoomOut(evt: any) { zoomOut(evt: any) {
appEvents.publish(new ZoomOutEvent(2)); appEvents.publish(new ZoomOutEvent({ scale: 2 }));
} }
onDataSnapshotLoad(snapshotData: any) { onDataSnapshotLoad(snapshotData: any) {

View File

@ -161,7 +161,7 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
} }
zoomOut(evt: any) { zoomOut(evt: any) {
appEvents.publish(new ZoomOutEvent(2)); appEvents.publish(new ZoomOutEvent({ scale: 2 }));
} }
onRender() { onRender() {

View File

@ -132,14 +132,25 @@ export class RenderEvent extends BusEventBase {
static type = 'render'; static type = 'render';
} }
export class ZoomOutEvent extends BusEventWithPayload<number> { interface ZoomOutEventPayload {
scale: number;
updateUrl?: boolean;
}
export class ZoomOutEvent extends BusEventWithPayload<ZoomOutEventPayload> {
static type = 'zoom-out'; static type = 'zoom-out';
} }
export enum ShiftTimeEventPayload { export enum ShiftTimeEventDirection {
Left = -1, Left = -1,
Right = 1, Right = 1,
} }
interface ShiftTimeEventPayload {
direction: ShiftTimeEventDirection;
updateUrl?: boolean;
}
export class ShiftTimeEvent extends BusEventWithPayload<ShiftTimeEventPayload> { export class ShiftTimeEvent extends BusEventWithPayload<ShiftTimeEventPayload> {
static type = 'shift-time'; static type = 'shift-time';
} }