Dashboard: Scroll element into view when selecting from outline (#102966)

* Dashboard: Scroll element into view when selecting from outline

* fix import

* Switch to tab when selected via outline

* Update

---------

Co-authored-by: Oscar Kilhed <oscar.kilhed@grafana.com>
This commit is contained in:
Torkel Ödegaard
2025-03-27 16:48:05 +01:00
committed by GitHub
co-authored by Oscar Kilhed
parent 73e0db452b
commit 5a916db4de
11 changed files with 96 additions and 12 deletions
@@ -37,6 +37,7 @@ function DashboardOutlineNode({ sceneObject, expandable }: { sceneObject: SceneO
const children = collectEditableElementChildren(sceneObject);
const elementInfo = editableElement.getEditableElementInfo();
const instanceName = elementInfo.instanceName === '' ? '<empty title>' : elementInfo.instanceName;
return (
<>
@@ -46,11 +47,12 @@ function DashboardOutlineNode({ sceneObject, expandable }: { sceneObject: SceneO
onPointerDown={(evt) => {
onSelect?.(evt);
setIsExpanded(!isExpanded);
editableElement.scrollIntoView?.();
}}
>
{expandable && <Icon name={isExpanded ? 'angle-down' : 'angle-right'} />}
<Icon size="sm" name={elementInfo.icon} />
<span>{elementInfo.instanceName}</span>
<span>{instanceName}</span>
</button>
{expandable && isExpanded && (
@@ -12,6 +12,7 @@ import {
PanelDescriptionTextArea,
PanelFrameTitleInput,
} from '../panel-edit/getPanelFrameOptions';
import { AutoGridItem } from '../scene/layout-responsive-grid/ResponsiveGridItem';
import { BulkActionElement } from '../scene/types/BulkActionElement';
import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
@@ -96,6 +97,12 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
public createMultiSelectedElement(items: VizPanelEditableElement[]) {
return new MultiSelectedVizPanelsEditableElement(items);
}
public scrollIntoView() {
if (this.panel.parent instanceof AutoGridItem) {
this.panel.parent.scrollIntoView();
}
}
}
type OpenPanelEditVizProps = {
@@ -1,4 +1,5 @@
import { isEqual } from 'lodash';
import React from 'react';
import {
CustomVariable,
@@ -18,6 +19,7 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components
import { ConditionalRendering } from '../../conditional-rendering/ConditionalRendering';
import { getCloneKey } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
@@ -43,9 +45,7 @@ export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements
});
public readonly isDashboardLayoutItem = true;
public _containerRef: HTMLDivElement | null = null;
public containerRef = React.createRef<HTMLDivElement>();
private _prevRepeatValues?: VariableValueSingle[];
public constructor(state: AutoGridItemState) {
@@ -153,18 +153,18 @@ export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements
return this.parent;
}
public setRef(ref: HTMLDivElement | null) {
this._containerRef = ref;
}
public getBoundingBox(): { width: number; height: number; top: number; left: number } {
const rect = this._containerRef!.getBoundingClientRect();
const rect = this.containerRef.current!.getBoundingClientRect();
return {
width: rect.width,
height: rect.height,
top: this._containerRef!.offsetTop,
left: this._containerRef!.offsetLeft,
top: this.containerRef.current!.offsetTop,
left: this.containerRef.current!.offsetLeft,
};
}
public scrollIntoView() {
scrollCanvasElementIntoView(this, this.containerRef);
}
}
@@ -35,7 +35,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
))}
</>
) : (
<div ref={(ref) => model.setRef(ref)} data-auto-grid-item-drop-target={isDragging ? key : undefined}>
<div ref={model.containerRef} data-auto-grid-item-drop-target={isDragging ? key : undefined}>
{isDragged && <div className={styles.draggedPlaceholder} />}
<div
@@ -1,3 +1,5 @@
import React from 'react';
import {
sceneGraph,
SceneObject,
@@ -14,6 +16,7 @@ import { ConditionalRendering } from '../../conditional-rendering/ConditionalRen
import { getDefaultVizPanel } from '../../utils/utils';
import { AutoGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { LayoutRestorer } from '../layouts-shared/LayoutRestorer';
import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
import { BulkActionElement } from '../types/BulkActionElement';
import { DashboardDropTarget } from '../types/DashboardDropTarget';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
@@ -50,6 +53,7 @@ export class RowItem
public readonly isEditableDashboardElement = true;
public readonly isDashboardDropTarget = true;
private _layoutRestorer = new LayoutRestorer();
public containerRef = React.createRef<HTMLDivElement>();
public constructor(state?: Partial<RowItemState>) {
super({
@@ -201,4 +205,8 @@ export class RowItem
private _getRepeatBehavior(): RowItemRepeaterBehavior | undefined {
return this.state.$behaviors?.find((b) => b instanceof RowItemRepeaterBehavior);
}
public scrollIntoView() {
scrollCanvasElementIntoView(this, this.containerRef);
}
}
@@ -42,6 +42,7 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
return (
<div
ref={model.containerRef}
data-dashboard-drop-target-key={model.state.key}
className={cx(
styles.wrapper,
@@ -1,3 +1,5 @@
import React from 'react';
import {
SceneObjectState,
SceneObjectBase,
@@ -13,6 +15,7 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components
import { getDefaultVizPanel } from '../../utils/utils';
import { AutoGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { LayoutRestorer } from '../layouts-shared/LayoutRestorer';
import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
import { BulkActionElement } from '../types/BulkActionElement';
import { DashboardDropTarget } from '../types/DashboardDropTarget';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
@@ -48,6 +51,7 @@ export class TabItem
public readonly isDashboardDropTarget = true;
private _layoutRestorer = new LayoutRestorer();
public containerRef = React.createRef<HTMLDivElement>();
constructor(state?: Partial<TabItemState>) {
super({
@@ -160,4 +164,13 @@ export class TabItem
private _getParentLayout(): TabsLayoutManager {
return sceneGraph.getAncestor(this, TabsLayoutManager);
}
public scrollIntoView(): void {
const tabsLayout = sceneGraph.getAncestor(this, TabsLayoutManager);
if (tabsLayout.getCurrentTab() !== this) {
tabsLayout.switchToTab(this);
}
scrollCanvasElementIntoView(this, this.containerRef);
}
}
@@ -22,6 +22,7 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
return (
<Tab
ref={model.containerRef}
truncate
className={cx(
isSelected && 'dashboard-selected-element',
@@ -87,6 +87,10 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
}
}
public switchToTab(tab: TabItem) {
this.setState({ currentTabIndex: this.state.tabs.indexOf(tab) });
}
public getCurrentTab(): TabItem {
return this.state.tabs.length > this.state.currentTabIndex
? this.state.tabs[this.state.currentTabIndex]
@@ -0,0 +1,43 @@
import { SceneObject } from '@grafana/scenes';
import { RowItem } from '../layout-rows/RowItem';
import { TabItem } from '../layout-tabs/TabItem';
import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
/**
* Will scroll element into view. If element is not connected yet, it will try to expand rows
* and switch tabs to make it visible.
*/
export function scrollCanvasElementIntoView(sceneObject: SceneObject, ref: React.RefObject<HTMLElement>) {
if (ref.current?.isConnected) {
scrollIntoView(ref.current);
return;
}
// try expanding rows and switching tabs
let parent = sceneObject.parent;
while (parent) {
if (parent instanceof RowItem && parent.state.collapse) {
parent.onCollapseToggle();
}
if (parent instanceof TabItem) {
const tabsManager = parent.parent;
if (tabsManager instanceof TabsLayoutManager && tabsManager.getCurrentTab() !== parent) {
tabsManager.switchToTab(parent);
}
}
parent = parent.parent;
}
// now try to scroll into view
setTimeout(() => {
if (ref.current?.isConnected) {
scrollIntoView(ref.current);
}
}, 10);
}
function scrollIntoView(element: HTMLElement) {
element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
}
@@ -44,6 +44,11 @@ export interface EditableDashboardElement {
* creates a new multi-selection element from a list of selected items
*/
createMultiSelectedElement?(elements: this[]): EditableDashboardElement;
/**
* scroll element into view (when selected from outline)
*/
scrollIntoView?(): void;
}
export interface EditableDashboardElementInfo {