Dashboard: New experimental time range zoom shortcuts (#114190)

* feat(toggle): new feature toggle for time range zoom keyboard shortcuts

* feat(keyboard-shortcuts): handle `t +` and `t -` key combinations

* test(keyboard-shortcuts): validate time range zoom with `t +`, and `t -`

* chore(i18n): update keyboard shortcut translations

* refactor(time-range): no-op when timespan is zero instead of defaulting
This commit is contained in:
Jesse David Peterson
2025-11-20 17:14:27 -04:00
committed by GitHub
parent 6f9c867660
commit ef00dd4940
10 changed files with 337 additions and 22 deletions
+4
View File
@@ -669,6 +669,10 @@ export interface FeatureToggles {
*/
timeRangePan?: boolean;
/**
* Enables new keyboard shortcuts for time range zoom operations
*/
newTimeRangeZoomShortcuts?: boolean;
/**
* Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default.
* @default false
*/
+7
View File
@@ -1099,6 +1099,13 @@ var (
FrontendOnly: true,
Owner: grafanaDatavizSquad,
},
{
Name: "newTimeRangeZoomShortcuts",
Description: "Enables new keyboard shortcuts for time range zoom operations",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaDatavizSquad,
},
{
Name: "azureMonitorDisableLogLimit",
Description: "Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default.",
+1
View File
@@ -152,6 +152,7 @@ pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false
unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false
timeRangeProvider,experimental,@grafana/grafana-frontend-platform,false,false,false
timeRangePan,experimental,@grafana/dataviz-squad,false,false,true
newTimeRangeZoomShortcuts,experimental,@grafana/dataviz-squad,false,false,true
azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false
playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false
passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
152 unifiedStorageBigObjectsSupport experimental @grafana/search-and-storage false false false
153 timeRangeProvider experimental @grafana/grafana-frontend-platform false false false
154 timeRangePan experimental @grafana/dataviz-squad false false true
155 newTimeRangeZoomShortcuts experimental @grafana/dataviz-squad false false true
156 azureMonitorDisableLogLimit GA @grafana/partner-datasources false false false
157 playlistsReconciler experimental @grafana/grafana-app-platform-squad false true false
158 passwordlessMagicLinkAuthentication experimental @grafana/identity-access-team false false false
+13
View File
@@ -2873,6 +2873,19 @@
"hideFromDocs": true
}
},
{
"metadata": {
"name": "newTimeRangeZoomShortcuts",
"resourceVersion": "1763646782694",
"creationTimestamp": "2025-11-20T13:53:02Z"
},
"spec": {
"description": "Enables new keyboard shortcuts for time range zoom operations",
"stage": "experimental",
"codeowner": "@grafana/dataviz-squad",
"frontend": true
}
},
{
"metadata": {
"name": "newVizSuggestions",
@@ -1,6 +1,7 @@
import { renderHook } from '@testing-library/react';
import { useAssistant } from '@grafana/assistant';
import { config } from '@grafana/runtime';
import { useShortcuts } from './HelpModal';
@@ -148,4 +149,73 @@ describe('useShortcuts', () => {
const assistantShortcut = globalCategory!.shortcuts.find((shortcut) => shortcut.keys.includes('ctrl + .'));
expect(assistantShortcut).toBeDefined();
});
describe('time range zoom shortcuts with feature toggle', () => {
beforeEach(() => {
mockUseAssistant.mockReturnValue({
isAvailable: false,
openAssistant: jest.fn(),
closeAssistant: jest.fn(),
toggleAssistant: jest.fn(),
});
});
it('should show new zoom shortcuts when feature toggle is enabled', () => {
config.featureToggles.newTimeRangeZoomShortcuts = true;
const { result } = renderHook(() => useShortcuts());
const timeRangeCategory = result.current.find((cat) => cat.category.includes('Time range'));
const zoomInShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('+'));
const zoomOutShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('-'));
expect(zoomInShortcut).toBeDefined();
expect(zoomInShortcut!.isNew).toBe(true);
expect(zoomOutShortcut).toBeDefined();
expect(zoomOutShortcut!.isNew).toBe(true);
});
it('should show legacy t z shortcut when feature toggle is disabled', () => {
config.featureToggles.newTimeRangeZoomShortcuts = false;
const { result } = renderHook(() => useShortcuts());
const timeRangeCategory = result.current.find((cat) => cat.category.includes('Time range'));
const legacyZoomShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('z'));
const newZoomInShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('+'));
const newZoomOutShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('-'));
expect(legacyZoomShortcut).toBeDefined();
expect(newZoomInShortcut).toBeUndefined();
expect(newZoomOutShortcut).toBeUndefined();
});
it('should not show isNew badge on legacy shortcuts', () => {
config.featureToggles.newTimeRangeZoomShortcuts = false;
const { result } = renderHook(() => useShortcuts());
const timeRangeCategory = result.current.find((cat) => cat.category.includes('Time range'));
const legacyZoomShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('z'));
expect(legacyZoomShortcut!.isNew).toBeUndefined();
});
it('should show isNew badge on new shortcuts when feature toggle is enabled', () => {
config.featureToggles.newTimeRangeZoomShortcuts = true;
const { result } = renderHook(() => useShortcuts());
const timeRangeCategory = result.current.find((cat) => cat.category.includes('Time range'));
const zoomInShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('+'));
const zoomOutShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('-'));
expect(zoomInShortcut!.isNew).toBe(true);
expect(zoomOutShortcut!.isNew).toBe(true);
});
});
});
+35 -10
View File
@@ -2,9 +2,10 @@ import { css } from '@emotion/css';
import { useMemo } from 'react';
import { useAssistant } from '@grafana/assistant';
import { GrafanaTheme2 } from '@grafana/data';
import { FeatureState, GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Grid, Modal, useStyles2, Text } from '@grafana/ui';
import { config } from '@grafana/runtime';
import { Grid, Modal, useStyles2, Text, FeatureBadge } from '@grafana/ui';
import { getModKey } from 'app/core/utils/browser';
export interface HelpModalProps {
@@ -36,7 +37,7 @@ export const HelpModal = ({ onDismiss }: HelpModalProps): JSX.Element => {
</tr>
</thead>
<tbody>
{shortcuts.map(({ keys, description }) => (
{shortcuts.map(({ keys, description, isNew }) => (
<tr key={keys.join()}>
<td className={styles.keys}>
{keys.map((key) => (
@@ -44,9 +45,12 @@ export const HelpModal = ({ onDismiss }: HelpModalProps): JSX.Element => {
))}
</td>
<td>
<Text variant="bodySmall" element="p">
{description}
</Text>
<div className={styles.descriptionWrapper}>
<Text variant="bodySmall" element="p">
{description}
</Text>
{isNew && <FeatureBadge featureState={FeatureState.new} />}
</div>
</td>
</tr>
))}
@@ -104,10 +108,25 @@ export const useShortcuts = () => {
{
category: t('help-modal.shortcuts-category.time-range', 'Time range'),
shortcuts: [
{
keys: ['t', 'z'],
description: t('help-modal.shortcuts-description.zoom-out-time-range', 'Zoom out time range'),
},
...(config.featureToggles.newTimeRangeZoomShortcuts
? [
{
keys: ['t', '+'],
description: t('help-modal.shortcuts-description.zoom-in-time-range', 'Zoom in time range'),
isNew: true,
},
{
keys: ['t', '-'],
description: t('help-modal.shortcuts-description.zoom-out-time-range', 'Zoom out time range'),
isNew: true,
},
]
: [
{
keys: ['t', 'z'],
description: t('help-modal.shortcuts-description.zoom-out-time-range', 'Zoom out time range'),
},
]),
{
keys: ['t', '←'],
description: t('help-modal.shortcuts-description.move-time-range-back', 'Move time range back'),
@@ -277,6 +296,12 @@ function getStyles(theme: GrafanaTheme2) {
whiteSpace: 'nowrap',
minWidth: 83, // To match column widths with the widest
}),
descriptionWrapper: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.75),
flexWrap: 'nowrap',
}),
shortcutTableKey: css({
display: 'inline-block',
textAlign: 'center',
+14 -4
View File
@@ -1,6 +1,6 @@
import { toggleAssistant, isAssistantAvailable } from '@grafana/assistant';
import { LegacyGraphHoverClearEvent, SetPanelAttentionEvent, locationUtil } from '@grafana/data';
import { LocationService } from '@grafana/runtime';
import { LocationService, config } from '@grafana/runtime';
import { appEvents } from 'app/core/app_events';
import { getExploreUrl } from 'app/core/utils/explore';
import { toggleMockApiAndReload, togglePseudoLocale } from 'app/dev-utils';
@@ -231,9 +231,19 @@ export class KeybindingSrv {
appEvents.publish(new AbsoluteTimeEvent({ updateUrl }));
});
this.bind('t z', () => {
appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl }));
});
if (config.featureToggles.newTimeRangeZoomShortcuts) {
this.bind('t +', () => {
appEvents.publish(new ZoomOutEvent({ scale: 0.5, updateUrl }));
});
this.bind('t -', () => {
appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl }));
});
} else {
this.bind('t z', () => {
appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl }));
});
}
this.bind('ctrl+z', () => {
appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl }));
@@ -1,4 +1,5 @@
import { LegacyGraphHoverClearEvent } from '@grafana/data';
import { config } from '@grafana/runtime';
import { behaviors, sceneGraph, SceneTimeRange } from '@grafana/scenes';
import { DashboardCursorSync } from '@grafana/schema';
import { appEvents } from 'app/core/app_events';
@@ -253,4 +254,146 @@ describe('setupKeyboardShortcuts', () => {
expect(drBinding).toBeDefined();
});
});
describe('time range zoom shortcuts with feature toggle', () => {
describe('when newTimeRangeZoomShortcuts is enabled', () => {
beforeEach(() => {
config.featureToggles.newTimeRangeZoomShortcuts = true;
jest.clearAllMocks();
});
it('should setup t + zoom in shortcut', () => {
setupKeyboardShortcuts(mockScene);
const tPlusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't +');
expect(tPlusBinding).toBeDefined();
});
it('should setup t - zoom out shortcut with keypress type', () => {
setupKeyboardShortcuts(mockScene);
const tMinusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't -');
expect(tMinusBinding).toBeDefined();
expect(tMinusBinding![0].type).toBe('keypress');
});
it('should not setup t z shortcut when feature toggle is on', () => {
setupKeyboardShortcuts(mockScene);
const tzBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't z');
expect(tzBinding).toBeUndefined();
});
});
describe('when newTimeRangeZoomShortcuts is disabled', () => {
beforeEach(() => {
config.featureToggles.newTimeRangeZoomShortcuts = false;
jest.clearAllMocks();
});
it('should setup legacy t z shortcut', () => {
setupKeyboardShortcuts(mockScene);
const tzBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't z');
expect(tzBinding).toBeDefined();
});
it('should not setup new zoom shortcuts when feature toggle is off', () => {
setupKeyboardShortcuts(mockScene);
const tPlusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't +');
const tMinusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't -');
expect(tPlusBinding).toBeUndefined();
expect(tMinusBinding).toBeUndefined();
});
});
describe('zoom handler logic', () => {
let mockTimeRange: ReturnType<typeof createMockTimeRange>;
function createMockTimeRange() {
return {
state: {
value: {
from: { valueOf: () => new Date('2024-01-01 12:00:00').getTime() },
to: { valueOf: () => new Date('2024-01-01 18:00:00').getTime() }, // 6 hour span
raw: { from: 'now-6h', to: 'now' },
},
},
onTimeRangeChange: jest.fn(),
} satisfies {
state: {
value: {
from: { valueOf: () => number };
to: { valueOf: () => number };
raw: { from: string; to: string };
};
};
onTimeRangeChange: jest.Mock;
};
}
beforeEach(() => {
config.featureToggles.newTimeRangeZoomShortcuts = true;
mockTimeRange = createMockTimeRange();
(sceneGraph.getTimeRange as jest.Mock).mockReturnValue(mockTimeRange);
jest.clearAllMocks();
});
it('should zoom in (scale 0.5) when t + is pressed', () => {
setupKeyboardShortcuts(mockScene);
const tPlusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't +');
const handler = tPlusBinding![0].onTrigger;
handler();
// Scale 0.5 should result in 3 hour span (half of 6)
expect(mockTimeRange.onTimeRangeChange).toHaveBeenCalledWith(
expect.objectContaining({
from: expect.any(Object),
to: expect.any(Object),
raw: expect.any(Object),
})
);
const call = mockTimeRange.onTimeRangeChange.mock.calls[0][0];
const newSpan = call.to.valueOf() - call.from.valueOf();
expect(newSpan).toBe(3 * 60 * 60 * 1000); // 3 hours in milliseconds
});
it('should keep center point when zooming in', () => {
setupKeyboardShortcuts(mockScene);
const tPlusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't +');
const handler = tPlusBinding![0].onTrigger;
const originalCenter = (mockTimeRange.state.value.from.valueOf() + mockTimeRange.state.value.to.valueOf()) / 2;
handler();
const call = mockTimeRange.onTimeRangeChange.mock.calls[0][0];
const newCenter = (call.from.valueOf() + call.to.valueOf()) / 2;
expect(newCenter).toBe(originalCenter);
});
it('should do nothing when timespan is zero', () => {
mockTimeRange.state.value.from.valueOf = () => new Date('2024-01-01 12:00:00').getTime();
mockTimeRange.state.value.to.valueOf = () => new Date('2024-01-01 12:00:00').getTime(); // Same time
setupKeyboardShortcuts(mockScene);
const tPlusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't +');
const handler = tPlusBinding![0].onTrigger;
handler();
// Should not call onTimeRangeChange when timespan is 0
expect(mockTimeRange.onTimeRangeChange).not.toHaveBeenCalled();
});
});
});
});
@@ -1,4 +1,4 @@
import { locationUtil, SetPanelAttentionEvent, LegacyGraphHoverClearEvent } from '@grafana/data';
import { locationUtil, SetPanelAttentionEvent, LegacyGraphHoverClearEvent, dateTime } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { behaviors, sceneGraph, VizPanel } from '@grafana/scenes';
import { appEvents } from 'app/core/app_events';
@@ -130,13 +130,29 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
onTrigger: () => sceneGraph.getTimeRange(scene).onRefresh(),
});
// Zoom out
keybindings.addBinding({
key: 't z',
onTrigger: () => {
handleZoomOut(scene);
},
});
if (config.featureToggles.newTimeRangeZoomShortcuts) {
keybindings.addBinding({
key: 't +',
onTrigger: () => {
handleZoom(scene, 0.5);
},
});
keybindings.addBinding({
key: 't -',
type: 'keypress', // NOTE: Because some browsers/OS identify minus symbol differently.
onTrigger: () => {
handleZoomOut(scene);
},
});
} else {
keybindings.addBinding({
key: 't z',
onTrigger: () => {
handleZoomOut(scene);
},
});
}
keybindings.addBinding({
key: 'ctrl+z',
@@ -266,6 +282,31 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
};
}
function handleZoom(scene: DashboardScene, scale: number) {
const timeRange = sceneGraph.getTimeRange(scene);
const currentRange = timeRange.state.value;
const timespan = currentRange.to.valueOf() - currentRange.from.valueOf();
if (timespan === 0) {
return;
}
const center = currentRange.to.valueOf() - timespan / 2;
const newTimespan = timespan * scale;
const to = center + newTimespan / 2;
const from = center - newTimespan / 2;
timeRange.onTimeRangeChange({
from: dateTime(from),
to: dateTime(to),
raw: {
from: dateTime(from),
to: dateTime(to),
},
});
}
function handleZoomOut(scene: DashboardScene) {
const timePicker = dashboardSceneGraph.getTimePicker(scene);
timePicker?.onZoom();
+1
View File
@@ -9297,6 +9297,7 @@
"toggle-panel-edit": "Toggle panel edit view",
"toggle-panel-fullscreen": "Toggle panel fullscreen view",
"toggle-panel-legend": "Toggle panel legend",
"zoom-in-time-range": "Zoom in time range",
"zoom-out-time-range": "Zoom out time range"
},
"title": "Shortcuts"