From ef00dd494061de0764aad659e3d0174117004938 Mon Sep 17 00:00:00 2001 From: Jesse David Peterson Date: Thu, 20 Nov 2025 17:14:27 -0400 Subject: [PATCH] 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 --- .../src/types/featureToggles.gen.ts | 4 + pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.json | 13 ++ .../core/components/help/HelpModal.test.tsx | 70 +++++++++ public/app/core/components/help/HelpModal.tsx | 45 ++++-- public/app/core/services/keybindingSrv.ts | 18 ++- .../scene/keyboardShortcuts.test.ts | 143 ++++++++++++++++++ .../scene/keyboardShortcuts.ts | 57 ++++++- public/locales/en-US/grafana.json | 1 + 10 files changed, 337 insertions(+), 22 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index a7d2378f765..e5c59000c8d 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -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 */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index c56f46a7193..28ff9e1809e 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 9465d6992c3..4ee6e57a9ed 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -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 diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 2298b91d854..bb0e03295a0 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -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", diff --git a/public/app/core/components/help/HelpModal.test.tsx b/public/app/core/components/help/HelpModal.test.tsx index d343c5560b0..a410f1cd4d6 100644 --- a/public/app/core/components/help/HelpModal.test.tsx +++ b/public/app/core/components/help/HelpModal.test.tsx @@ -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); + }); + }); }); diff --git a/public/app/core/components/help/HelpModal.tsx b/public/app/core/components/help/HelpModal.tsx index 4017443b252..82f39fac28f 100644 --- a/public/app/core/components/help/HelpModal.tsx +++ b/public/app/core/components/help/HelpModal.tsx @@ -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 => { - {shortcuts.map(({ keys, description }) => ( + {shortcuts.map(({ keys, description, isNew }) => ( {keys.map((key) => ( @@ -44,9 +45,12 @@ export const HelpModal = ({ onDismiss }: HelpModalProps): JSX.Element => { ))} - - {description} - +
+ + {description} + + {isNew && } +
))} @@ -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', diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 865f461ce84..ea232baffcc 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -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 })); diff --git a/public/app/features/dashboard-scene/scene/keyboardShortcuts.test.ts b/public/app/features/dashboard-scene/scene/keyboardShortcuts.test.ts index 856f8df3193..9a70858f39b 100644 --- a/public/app/features/dashboard-scene/scene/keyboardShortcuts.test.ts +++ b/public/app/features/dashboard-scene/scene/keyboardShortcuts.test.ts @@ -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; + + 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(); + }); + }); + }); }); diff --git a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts index b6ebf75bf44..1113b8a8a8c 100644 --- a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts +++ b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts @@ -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(); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index ba66da11bbf..9539dbefc30 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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"