diff --git a/public/app/core/services/KeybindingSet.ts b/public/app/core/services/KeybindingSet.ts new file mode 100644 index 00000000000..b3316c7122f --- /dev/null +++ b/public/app/core/services/KeybindingSet.ts @@ -0,0 +1,38 @@ +import Mousetrap from 'mousetrap'; + +export interface KeyBindingItem { + /** Key or key pattern like mod+o */ + key: string; + /** Defaults to keydown */ + type?: string; + /** The handler callback */ + onTrigger: () => void; +} + +/** + * Small util to make it easier to add and unbind Mousetrap keybindings + */ +export class KeybindingSet { + private _binds: KeyBindingItem[] = []; + + addBinding(item: KeyBindingItem) { + Mousetrap.bind( + item.key, + (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + evt.returnValue = false; + item.onTrigger(); + }, + 'keydown' + ); + this._binds.push(item); + } + + removeAll() { + this._binds.forEach((item) => { + Mousetrap.unbind(item.key, item.type); + }); + this._binds = []; + } +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index ee947819c40..7d0416a645f 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -15,7 +15,7 @@ import { } from '@grafana/scenes'; import { DashboardScene } from '../scene/DashboardScene'; -import { getDashboardUrl } from '../utils/utils'; +import { getDashboardUrl } from '../utils/urlBuilders'; import { PanelEditorRenderer } from './PanelEditorRenderer'; import { PanelOptionsPane } from './PanelOptionsPane'; diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 30ed8897580..4a849ee9c94 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -20,15 +20,11 @@ import { DashboardMeta } from 'app/types'; import { DashboardSceneRenderer } from '../scene/DashboardSceneRenderer'; import { SaveDashboardDrawer } from '../serialization/SaveDashboardDrawer'; import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; -import { - findVizPanelByKey, - forceRenderChildren, - getClosestVizPanel, - getDashboardUrl, - getPanelIdForVizPanel, -} from '../utils/utils'; +import { getDashboardUrl } from '../utils/urlBuilders'; +import { findVizPanelByKey, forceRenderChildren, getClosestVizPanel, getPanelIdForVizPanel } from '../utils/utils'; import { DashboardSceneUrlSync } from './DashboardSceneUrlSync'; +import { setupKeyboardShortcuts } from './keyboardShortcuts'; export interface DashboardSceneState extends SceneObjectState { /** The title */ @@ -95,6 +91,7 @@ export class DashboardScene extends SceneObjectBase { this.startTrackingChanges(); } + const clearKeyBindings = setupKeyboardShortcuts(this); const oldDashboardWrapper = new DashboardModelCompatibilityWrapper(this); // @ts-expect-error @@ -103,6 +100,7 @@ export class DashboardScene extends SceneObjectBase { // Deactivation logic return () => { window.__grafanaSceneContext = undefined; + clearKeyBindings(); this.stopTrackingChanges(); this.stopUrlSync(); oldDashboardWrapper.destroy(); diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index aa03646e03a..f377ab996c7 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -1,13 +1,12 @@ -import { locationUtil, PanelMenuItem } from '@grafana/data'; +import { PanelMenuItem } from '@grafana/data'; import { locationService, reportInteraction } from '@grafana/runtime'; -import { sceneGraph, VizPanel, VizPanelMenu } from '@grafana/scenes'; -import { contextSrv } from 'app/core/core'; +import { VizPanel, VizPanelMenu } from '@grafana/scenes'; import { t } from 'app/core/internationalization'; -import { getExploreUrl } from 'app/core/utils/explore'; import { InspectTab } from 'app/features/inspector/types'; import { ShareModal } from '../sharing/ShareModal'; -import { getDashboardUrl, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils'; +import { getDashboardUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; +import { getPanelIdForVizPanel } from '../utils/utils'; import { DashboardScene } from './DashboardScene'; @@ -23,8 +22,6 @@ export function panelMenuBehavior(menu: VizPanelMenu) { const items: PanelMenuItem[] = []; const panelId = getPanelIdForVizPanel(panel); const dashboard = panel.getRoot(); - const panelPlugin = panel.getPlugin(); - const queryRunner = getQueryRunnerFor(panel); if (dashboard instanceof DashboardScene) { items.push({ @@ -32,7 +29,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) { iconClassName: 'eye', shortcut: 'v', onClick: () => reportInteraction('dashboards_panelheader_menu', { item: 'view' }), - href: locationUtil.getUrlForPartial(location, { viewPanel: panel.state.key }), + href: getViewPanelUrl(panel), }); // We could check isEditing here but I kind of think this should always be in the menu, @@ -40,7 +37,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) { items.push({ text: t('panel.header-menu.edit', `Edit`), iconClassName: 'eye', - shortcut: 'v', + shortcut: 'e', onClick: () => reportInteraction('dashboards_panelheader_menu', { item: 'edit' }), href: getDashboardUrl({ uid: dashboard.state.uid, @@ -60,20 +57,14 @@ export function panelMenuBehavior(menu: VizPanelMenu) { }); } - if (contextSrv.hasAccessToExplore() && !panelPlugin?.meta.skipDataQuery && queryRunner) { - const timeRange = sceneGraph.getTimeRange(panel); - + const exploreUrl = await tryGetExploreUrlForPanel(panel); + if (exploreUrl) { items.push({ text: t('panel.header-menu.explore', `Explore`), iconClassName: 'compass', shortcut: 'p x', onClick: () => reportInteraction('dashboards_panelheader_menu', { item: 'explore' }), - href: await getExploreUrl({ - queries: queryRunner.state.queries, - dsRef: queryRunner.state.datasource, - timeRange: timeRange.state.value, - scopedVars: { __sceneObject: { value: panel } }, - }), + href: exploreUrl, }); } @@ -82,7 +73,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) { iconClassName: 'info-circle', shortcut: 'i', onClick: () => reportInteraction('dashboards_panelheader_menu', { item: 'inspect', tab: InspectTab.Data }), - href: locationUtil.getUrlForPartial(location, { inspect: panel.state.key }), + href: getInspectUrl(panel), }); menu.setState({ items }); diff --git a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts new file mode 100644 index 00000000000..7d44ce2e6b2 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts @@ -0,0 +1,124 @@ +import { locationService } from '@grafana/runtime'; +import { sceneGraph, VizPanel } from '@grafana/scenes'; +import { OptionsWithLegend } from '@grafana/schema'; +import { KeybindingSet } from 'app/core/services/KeybindingSet'; + +import { ShareModal } from '../sharing/ShareModal'; +import { getDashboardUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; +import { getPanelIdForVizPanel } from '../utils/utils'; + +import { DashboardScene } from './DashboardScene'; + +export function setupKeyboardShortcuts(scene: DashboardScene) { + const keybindings = new KeybindingSet(); + + // View panel + keybindings.addBinding({ + key: 'v', + onTrigger: withFocusedPanel(scene, (vizPanel: VizPanel) => { + if (!scene.state.viewPanelKey) { + locationService.push(getViewPanelUrl(vizPanel)); + } + }), + }); + + // Panel edit + keybindings.addBinding({ + key: 'e', + onTrigger: withFocusedPanel(scene, async (vizPanel: VizPanel) => { + const sceneRoot = vizPanel.getRoot(); + if (sceneRoot instanceof DashboardScene) { + const panelId = getPanelIdForVizPanel(vizPanel); + locationService.push( + getDashboardUrl({ + uid: sceneRoot.state.uid, + subPath: `/panel-edit/${panelId}`, + currentQueryParams: location.search, + }) + ); + } + }), + }); + + // Panel share + keybindings.addBinding({ + key: 'p s', + onTrigger: withFocusedPanel(scene, async (vizPanel: VizPanel) => { + scene.showModal(new ShareModal({ panelRef: vizPanel.getRef(), dashboardRef: scene.getRef() })); + }), + }); + + // Panel inspect + keybindings.addBinding({ + key: 'i', + onTrigger: withFocusedPanel(scene, async (vizPanel: VizPanel) => { + locationService.push(getInspectUrl(vizPanel)); + }), + }); + + // Got to Explore for panel + keybindings.addBinding({ + key: 'p x', + onTrigger: withFocusedPanel(scene, async (vizPanel: VizPanel) => { + const url = await tryGetExploreUrlForPanel(vizPanel); + if (url) { + locationService.push(url); + } + }), + }); + + // Toggle legend + keybindings.addBinding({ + key: 'p l', + onTrigger: withFocusedPanel(scene, toggleVizPanelLegend), + }); + + // Refresh + keybindings.addBinding({ + key: 'd r', + onTrigger: () => sceneGraph.getTimeRange(scene).onRefresh(), + }); + + // toggle all panel legends (TODO) + // delete panel (TODO when we work on editing) + // toggle all exemplars (TODO) + // collapse all rows (TODO) + // expand all rows (TODO) + + return () => keybindings.removeAll; +} + +export function withFocusedPanel(scene: DashboardScene, fn: (vizPanel: VizPanel) => void) { + return () => { + const elements = document.querySelectorAll(':hover'); + + for (let i = elements.length - 1; i > 0; i--) { + const element = elements[i]; + + if (element instanceof HTMLElement && element.dataset?.vizPanelKey) { + const panelKey = element.dataset?.vizPanelKey; + const vizPanel = sceneGraph.findObject(scene, (o) => o.state.key === panelKey); + + if (vizPanel && vizPanel instanceof VizPanel) { + fn(vizPanel); + return; + } + } + } + }; +} + +export function toggleVizPanelLegend(vizPanel: VizPanel) { + const options = vizPanel.state.options; + if (hasLegendOptions(options) && typeof options.legend.showLegend === 'boolean') { + vizPanel.onOptionsChange({ + legend: { + showLegend: options.legend.showLegend ? false : true, + }, + }); + } +} + +function hasLegendOptions(optionsWithLegend: unknown): optionsWithLegend is OptionsWithLegend { + return optionsWithLegend != null && 'legend' in optionsWithLegend; +} diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx index 52dc9c2f6ae..f5c2db5387a 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx @@ -13,7 +13,7 @@ import { trackDashboardSharingActionPerType } from 'app/features/dashboard/compo import { shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; import { DashboardScene } from '../scene/DashboardScene'; -import { getDashboardUrl } from '../utils/utils'; +import { getDashboardUrl } from '../utils/urlBuilders'; import { SceneShareTabState } from './types'; export interface ShareLinkTabState extends SceneShareTabState, ShareOptions { diff --git a/public/app/features/dashboard-scene/utils/utils.test.ts b/public/app/features/dashboard-scene/utils/urlBuilders.test.ts similarity index 94% rename from public/app/features/dashboard-scene/utils/utils.test.ts rename to public/app/features/dashboard-scene/utils/urlBuilders.test.ts index fb06896a58b..7c7e2899f4c 100644 --- a/public/app/features/dashboard-scene/utils/utils.test.ts +++ b/public/app/features/dashboard-scene/utils/urlBuilders.test.ts @@ -1,4 +1,4 @@ -import { getDashboardUrl } from './utils'; +import { getDashboardUrl } from './urlBuilders'; describe('dashboard utils', () => { it('Can getUrl', () => { diff --git a/public/app/features/dashboard-scene/utils/urlBuilders.ts b/public/app/features/dashboard-scene/utils/urlBuilders.ts new file mode 100644 index 00000000000..1539a04f348 --- /dev/null +++ b/public/app/features/dashboard-scene/utils/urlBuilders.ts @@ -0,0 +1,90 @@ +import { locationUtil, UrlQueryMap, urlUtil } from '@grafana/data'; +import { config, locationSearchToObject, locationService } from '@grafana/runtime'; +import { sceneGraph, VizPanel } from '@grafana/scenes'; +import { contextSrv } from 'app/core/core'; +import { getExploreUrl } from 'app/core/utils/explore'; + +import { getQueryRunnerFor } from './utils'; + +export interface DashboardUrlOptions { + uid?: string; + subPath?: string; + updateQuery?: UrlQueryMap; + /** Set to location.search to preserve current params */ + currentQueryParams: string; + /** * Returns solo panel route instead */ + soloRoute?: boolean; + /** return render url */ + render?: boolean; + /** Return an absolute URL */ + absolute?: boolean; + // Add tz to query params + timeZone?: string; +} + +export function getDashboardUrl(options: DashboardUrlOptions) { + let path = `/scenes/dashboard/${options.uid}${options.subPath ?? ''}`; + + if (options.soloRoute) { + path = `/d-solo/${options.uid}${options.subPath ?? ''}`; + } + + if (options.render) { + path = '/render' + path; + + options.updateQuery = { + ...options.updateQuery, + width: 1000, + height: 500, + tz: options.timeZone, + }; + } + + const params = options.currentQueryParams ? locationSearchToObject(options.currentQueryParams) : {}; + + if (options.updateQuery) { + for (const key of Object.keys(options.updateQuery)) { + // removing params with null | undefined + if (options.updateQuery[key] === null || options.updateQuery[key] === undefined) { + delete params[key]; + } else { + params[key] = options.updateQuery[key]; + } + } + } + + const relativeUrl = urlUtil.renderUrl(path, params); + + if (options.absolute) { + return config.appUrl + relativeUrl.slice(1); + } + + return relativeUrl; +} + +export function getViewPanelUrl(vizPanel: VizPanel) { + return locationUtil.getUrlForPartial(locationService.getLocation(), { viewPanel: vizPanel.state.key }); +} + +export function getInspectUrl(vizPanel: VizPanel) { + return locationUtil.getUrlForPartial(locationService.getLocation(), { inspect: vizPanel.state.key }); +} + +export function tryGetExploreUrlForPanel(vizPanel: VizPanel): Promise { + //const dashboard = panel.getRoot(); + const panelPlugin = vizPanel.getPlugin(); + const queryRunner = getQueryRunnerFor(vizPanel); + + if (!contextSrv.hasAccessToExplore() || panelPlugin?.meta.skipDataQuery || !queryRunner) { + return Promise.resolve(undefined); + } + + const timeRange = sceneGraph.getTimeRange(vizPanel); + + return getExploreUrl({ + queries: queryRunner.state.queries, + dsRef: queryRunner.state.datasource, + timeRange: timeRange.state.value, + scopedVars: { __sceneObject: { value: vizPanel } }, + }); +} diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index b28c27c8834..1a9e8cb76b6 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -1,5 +1,4 @@ -import { IntervalVariableModel, UrlQueryMap, urlUtil } from '@grafana/data'; -import { config, locationSearchToObject } from '@grafana/runtime'; +import { IntervalVariableModel } from '@grafana/data'; import { MultiValueVariable, SceneDataTransformer, @@ -79,62 +78,6 @@ export function forceRenderChildren(model: SceneObject, recursive?: boolean) { }); } -export interface DashboardUrlOptions { - uid?: string; - subPath?: string; - updateQuery?: UrlQueryMap; - /** Set to location.search to preserve current params */ - currentQueryParams: string; - /** * Returns solo panel route instead */ - soloRoute?: boolean; - /** return render url */ - render?: boolean; - /** Return an absolute URL */ - absolute?: boolean; - // Add tz to query params - timeZone?: string; -} - -export function getDashboardUrl(options: DashboardUrlOptions) { - let path = `/scenes/dashboard/${options.uid}${options.subPath ?? ''}`; - - if (options.soloRoute) { - path = `/d-solo/${options.uid}${options.subPath ?? ''}`; - } - - if (options.render) { - path = '/render' + path; - - options.updateQuery = { - ...options.updateQuery, - width: 1000, - height: 500, - tz: options.timeZone, - }; - } - - const params = options.currentQueryParams ? locationSearchToObject(options.currentQueryParams) : {}; - - if (options.updateQuery) { - for (const key of Object.keys(options.updateQuery)) { - // removing params with null | undefined - if (options.updateQuery[key] === null || options.updateQuery[key] === undefined) { - delete params[key]; - } else { - params[key] = options.updateQuery[key]; - } - } - } - - const relativeUrl = urlUtil.renderUrl(path, params); - - if (options.absolute) { - return config.appUrl + relativeUrl.slice(1); - } - - return relativeUrl; -} - export function getMultiVariableValues(variable: MultiValueVariable) { const { value, text, options } = variable.state;