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:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
|
+13
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user