DashboardScene: Panel Keybindings and some others (#76233)

* DashboardScene: Keybindings like v to view panel

* more bindings

* Fix imports

* Progress

* Nit update

* Fix merge

---------

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Torkel Ödegaard 2023-10-20 15:22:56 +02:00 committed by GitHub
parent 32fc55ee98
commit 7759e2f8a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 271 additions and 87 deletions

View File

@ -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 = [];
}
}

View File

@ -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';

View File

@ -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<DashboardSceneState> {
this.startTrackingChanges();
}
const clearKeyBindings = setupKeyboardShortcuts(this);
const oldDashboardWrapper = new DashboardModelCompatibilityWrapper(this);
// @ts-expect-error
@ -103,6 +100,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
// Deactivation logic
return () => {
window.__grafanaSceneContext = undefined;
clearKeyBindings();
this.stopTrackingChanges();
this.stopUrlSync();
oldDashboardWrapper.destroy();

View File

@ -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 });

View File

@ -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;
}

View File

@ -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 {

View File

@ -1,4 +1,4 @@
import { getDashboardUrl } from './utils';
import { getDashboardUrl } from './urlBuilders';
describe('dashboard utils', () => {
it('Can getUrl', () => {

View File

@ -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<string | undefined> {
//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 } },
});
}

View File

@ -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;