Dashboard: Keyboard and mouse panel shortcuts improvement (#87317)

* Add custom attention state to dashboard

* Add attention state to DashboardGrid

* Remove old functionality

* Add attention state to keybindingSrv

* Create PanelAttentionService

* Add PanelAttentionService with scenes support

* Remove unused code

* Use viz panel key instead of VizPanel

* Add type assertion

* Add e2e test

* Update comments

* Use panel id for non-scenes use case

* Support undefined service use case

* Memoize singleton call

* Debounce mouseover

* Set panelAttention with appEvents

* Use AppEvents for Scenes

* Remove panelAttentionSrv

* Wait in e2e to handle debounce

* Move subscription to KeybindingSrv

* Remove imports and reset keyboardShortcuts from main

* Fix on* event handlers
This commit is contained in:
Tobias Skarhed 2024-05-29 09:11:23 +02:00 committed by GitHub
parent 66d6b3d83b
commit 06304894a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 78 additions and 18 deletions

View File

@ -0,0 +1,30 @@
import { e2e } from '../utils';
describe('Dashboard Panel Attention', () => {
beforeEach(() => {
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
// Open all panels dashboard
e2e.flows.openDashboard({ uid: 'n1jR8vnnz' });
});
it('Should give panel attention on focus', () => {
e2e.components.Panels.Panel.title('State timeline').focus();
cy.get('body').type('v');
cy.url().should('include', 'viewPanel=41');
});
it('Should give panel attention on hover', () => {
e2e.components.Panels.Panel.title('State timeline').trigger('mousemove');
cy.wait(100); // Wait because of debounce
cy.get('body').type('v');
cy.url().should('include', 'viewPanel=41');
});
it('Should change panel attention between focus and mousemove', () => {
e2e.components.Panels.Panel.title('Size, color mapped to different fields + share view').focus();
e2e.components.Panels.Panel.title('State timeline').trigger('mousemove');
cy.wait(100); // Wait because of debounce
cy.get('body').type('v');
cy.url().should('include', 'viewPanel=41');
});
});

View File

@ -64,3 +64,7 @@ export class DataSourceTestSucceeded extends BusEventBase {
export class DataSourceTestFailed extends BusEventBase {
static type = 'datasource-test-failed';
}
export class SetPanelAttentionEvent extends BusEventWithPayload<{ panelId: string | number }> {
static type = 'set-panel-attention';
}

View File

@ -56,6 +56,14 @@ interface BaseProps {
* callback when opening the panel menu
*/
onOpenMenu?: () => void;
/**
* Used for setting panel attention
*/
onFocus?: () => void;
/**
* Debounce the event handler, if possible
*/
onMouseMove?: () => void;
}
interface FixedDimensions extends BaseProps {
@ -121,6 +129,8 @@ export function PanelChrome({
collapsible = false,
collapsed,
onToggleCollapse,
onFocus,
onMouseMove,
}: PanelChromeProps) {
const theme = useTheme2();
const styles = useStyles2(getStyles);
@ -240,7 +250,9 @@ export function PanelChrome({
className={cx(styles.container, { [styles.transparentContainer]: isPanelTransparent })}
style={containerStyles}
data-testid={testid}
tabIndex={0} //eslint-disable-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0} // eslint-disable-line jsx-a11y/no-noninteractive-tabindex
onFocus={onFocus}
onMouseMove={onMouseMove}
ref={ref}
>
<div className={styles.loadingBarContainer}>

View File

@ -2,7 +2,7 @@ import Mousetrap from 'mousetrap';
import 'mousetrap-global-bind';
import 'mousetrap/plugins/global-bind/mousetrap-global-bind';
import { LegacyGraphHoverClearEvent, locationUtil } from '@grafana/data';
import { LegacyGraphHoverClearEvent, SetPanelAttentionEvent, locationUtil } from '@grafana/data';
import { LocationService } from '@grafana/runtime';
import appEvents from 'app/core/app_events';
import { getExploreUrl } from 'app/core/utils/explore';
@ -27,13 +27,19 @@ import { contextSrv } from '../core';
import { RouteDescriptor } from '../navigation/types';
import { toggleTheme } from './theme';
import { withFocusedPanel } from './withFocusedPanelId';
export class KeybindingSrv {
constructor(
private locationService: LocationService,
private chromeService: AppChromeService
) {}
) {
// No cleanup needed, since KeybindingSrv is a singleton
appEvents.subscribe(SetPanelAttentionEvent, (event) => {
this.panelId = event.payload.panelId;
});
}
/** string for VizPanel key and number for panelId */
private panelId: string | number | null = null;
clearAndInitGlobalBindings(route: RouteDescriptor) {
Mousetrap.reset();
@ -182,7 +188,16 @@ export class KeybindingSrv {
}
bindWithPanelId(keyArg: string, fn: (panelId: number) => void) {
this.bind(keyArg, withFocusedPanel(fn));
this.bind(keyArg, this.withFocusedPanel(fn));
}
withFocusedPanel(fn: (panelId: number) => void) {
return () => {
if (typeof this.panelId === 'number') {
fn(this.panelId);
return;
}
};
}
setupTimeRangeBindings(updateUrl = true) {

View File

@ -1,12 +0,0 @@
export function withFocusedPanel(fn: (panelId: number) => 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?.panelid) {
fn(parseInt(element.dataset?.panelid, 10));
}
}
};
}

View File

@ -1,3 +1,4 @@
import { debounce } from 'lodash';
import React, { PureComponent } from 'react';
import { Subscription } from 'rxjs';
@ -16,6 +17,7 @@ import {
PanelData,
PanelPlugin,
PanelPluginMeta,
SetPanelAttentionEvent,
TimeRange,
toDataFrameDTO,
toUtc,
@ -30,6 +32,7 @@ import {
SeriesVisibilityChangeMode,
AdHocFilterItem,
} from '@grafana/ui';
import appEvents from 'app/core/app_events';
import config from 'app/core/config';
import { profiler } from 'app/core/profiler';
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
@ -90,6 +93,7 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
// Can this eventBus be on PanelModel? when we have more complex event filtering, that may be a better option
const eventBus = props.dashboard.events.newScopedBus(`panel:${props.panel.id}`, this.eventFilter);
this.debouncedSetPanelAttention = debounce(this.setPanelAttention.bind(this), 100);
this.state = {
isFirstLoad: true,
@ -544,11 +548,16 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
);
}
setPanelAttention() {
appEvents.publish(new SetPanelAttentionEvent({ panelId: this.props.panel.id }));
}
debouncedSetPanelAttention() {}
render() {
const { dashboard, panel, width, height, plugin } = this.props;
const { errorMessage, data } = this.state;
const { transparent } = panel;
const panelChromeProps = getPanelChromeProps({ ...this.props, data });
// Shift the hover menu down if it's on the top row so it doesn't get clipped by topnav
@ -579,6 +588,8 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
displayMode={transparent ? 'transparent' : 'default'}
onCancelQuery={panelChromeProps.onCancelQuery}
onOpenMenu={panelChromeProps.onOpenMenu}
onFocus={() => this.setPanelAttention()}
onMouseMove={() => this.debouncedSetPanelAttention()}
>
{(innerWidth, innerHeight) => (
<>