mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
32fc55ee98
commit
7759e2f8a3
38
public/app/core/services/KeybindingSet.ts
Normal file
38
public/app/core/services/KeybindingSet.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -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();
|
||||
|
@ -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 });
|
||||
|
124
public/app/features/dashboard-scene/scene/keyboardShortcuts.ts
Normal file
124
public/app/features/dashboard-scene/scene/keyboardShortcuts.ts
Normal 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;
|
||||
}
|
@ -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 {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { getDashboardUrl } from './utils';
|
||||
import { getDashboardUrl } from './urlBuilders';
|
||||
|
||||
describe('dashboard utils', () => {
|
||||
it('Can getUrl', () => {
|
90
public/app/features/dashboard-scene/utils/urlBuilders.ts
Normal file
90
public/app/features/dashboard-scene/utils/urlBuilders.ts
Normal 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 } },
|
||||
});
|
||||
}
|
@ -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;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user