Dashboard: Edit mode element selection (#97718)

* Dashboard: Edit mode elemenmt selection

* Add new panel to rows layout (#97729)

* Add new panel to rows layout

* Use `DashboardEditPane` to obtain selected object

* Fixed rows bottom padding issue

* Update

* Minor fix

* Update

* Update

* Update

---------

Co-authored-by: Bogdan Matei <bogdan.matei@grafana.com>
This commit is contained in:
Torkel Ödegaard 2025-01-13 12:15:16 +01:00 committed by GitHub
parent 0f9b107201
commit e0cfd12fb3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 168 additions and 88 deletions

View File

@ -328,4 +328,5 @@ export {
ElementSelectionContext,
useElementSelection,
type ElementSelectionContextState,
type ElementSelectionContextItem,
} from './ElementSelectionContext/ElementSelectionContext';

View File

@ -2,27 +2,78 @@ import { css } from '@emotion/css';
import { useEffect, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneObjectState, SceneObjectBase, SceneObject, SceneObjectRef } from '@grafana/scenes';
import { ToolbarButton, useStyles2 } from '@grafana/ui';
import {
SceneObjectState,
SceneObjectBase,
SceneObject,
SceneObjectRef,
sceneGraph,
useSceneObjectState,
} from '@grafana/scenes';
import { ElementSelectionContextItem, ElementSelectionContextState, ToolbarButton, useStyles2 } from '@grafana/ui';
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types';
import { getDashboardSceneFor } from '../utils/utils';
import { ElementEditPane } from './ElementEditPane';
import { useEditableElement } from './useEditableElement';
export interface DashboardEditPaneState extends SceneObjectState {
selectedObject?: SceneObjectRef<SceneObject>;
selectionContext: ElementSelectionContextState;
}
export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
public selectObject(obj: SceneObject) {
public constructor() {
super({
selectionContext: {
enabled: false,
selected: [],
onSelect: (item, multi) => this.selectElement(item, multi),
},
});
}
public enableSelection() {
// Enable element selection
this.setState({ selectionContext: { ...this.state.selectionContext, enabled: true } });
}
public disableSelection() {
this.setState({ selectionContext: { ...this.state.selectionContext, enabled: false } });
}
private selectElement(element: ElementSelectionContextItem, multi?: boolean) {
const obj = sceneGraph.findByKey(this, element.id);
if (obj) {
this.selectObject(obj, element.id, multi);
}
}
public selectObject(obj: SceneObject, id: string, multi?: boolean) {
const currentSelection = this.state.selectedObject?.resolve();
if (currentSelection === obj) {
const dashboard = getDashboardSceneFor(this);
this.setState({ selectedObject: dashboard.getRef() });
} else {
this.setState({ selectedObject: obj.getRef() });
this.clearSelection();
return;
}
this.setState({
selectedObject: obj.getRef(),
selectionContext: {
...this.state.selectionContext,
selected: [{ id }],
},
});
}
public clearSelection() {
const dashboard = getDashboardSceneFor(this);
this.setState({
selectedObject: dashboard.getRef(),
selectionContext: {
...this.state.selectionContext,
selected: [],
},
});
}
}
@ -42,14 +93,20 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla
const dashboard = getDashboardSceneFor(editPane);
editPane.setState({ selectedObject: dashboard.getRef() });
}
editPane.activate();
editPane.enableSelection();
return () => {
editPane.disableSelection();
};
}, [editPane]);
const { selectedObject } = editPane.useState();
const { selectedObject } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true });
const styles = useStyles2(getStyles);
const paneRef = useRef<HTMLDivElement>(null);
const editableElement = useEditableElement(selectedObject?.resolve());
if (!selectedObject) {
if (!editableElement) {
return null;
}
@ -68,29 +125,13 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla
);
}
const element = getEditableElementFor(selectedObject.resolve());
return (
<div className={styles.wrapper} ref={paneRef}>
<ElementEditPane element={element} key={element.getTypeName()} />
<ElementEditPane element={editableElement} key={editableElement.getTypeName()} />
</div>
);
}
function getEditableElementFor(obj: SceneObject): EditableDashboardElement {
if (isEditableDashboardElement(obj)) {
return obj;
}
for (const behavior of obj.state.$behaviors ?? []) {
if (isEditableDashboardElement(behavior)) {
return behavior;
}
}
throw new Error("Can't find editable element for selected object");
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({

View File

@ -3,7 +3,8 @@ import React, { CSSProperties, useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { config, useChromeHeaderHeight } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
import { useSceneObjectState } from '@grafana/scenes';
import { ElementSelectionContext, useStyles2 } from '@grafana/ui';
import NativeScrollbar from 'app/core/components/NativeScrollbar';
import { useSnappingSplitter } from '../panel-edit/splitter/useSnappingSplitter';
@ -22,6 +23,7 @@ interface Props {
export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls }: Props) {
const headerHeight = useChromeHeaderHeight();
const { editPane } = dashboard.state;
const styles = useStyles2(getStyles, headerHeight ?? 0);
const [isCollapsed, setIsCollapsed] = useEditPaneCollapsed();
@ -55,6 +57,7 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
setIsCollapsed(splitterState.collapsed);
}, [splitterState.collapsed, setIsCollapsed]);
const { selectionContext } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true });
const containerStyle: CSSProperties = {};
if (!isEditing) {
@ -70,12 +73,16 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
return (
<div {...containerProps} style={containerStyle}>
<div {...primaryProps} className={cx(primaryProps.className, styles.canvasWithSplitter)}>
<div
{...primaryProps}
className={cx(primaryProps.className, styles.canvasWithSplitter)}
onPointerDown={() => editPane.clearSelection()}
>
<NavToolbarActions dashboard={dashboard} />
<div className={cx(!isEditing && styles.controlsWrapperSticky)}>{controls}</div>
<div className={styles.bodyWrapper}>
<div className={cx(styles.body, isEditing && styles.bodyEditing)} ref={onBodyRef}>
{body}
<ElementSelectionContext.Provider value={selectionContext}>{body}</ElementSelectionContext.Provider>
</div>
</div>
</div>
@ -84,7 +91,7 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
<div {...splitterProps} data-edit-pane-splitter={true} />
<div {...secondaryProps} className={cx(secondaryProps.className, styles.editPane)}>
<DashboardEditPaneRenderer
editPane={dashboard.state.editPane}
editPane={editPane}
isCollapsed={splitterState.collapsed}
onToggleCollapse={onToggleCollapse}
/>
@ -136,6 +143,8 @@ function getStyles(theme: GrafanaTheme2, headerHeight: number) {
bottom: 0,
overflow: 'auto',
scrollbarWidth: 'thin',
// The fixed controls headers is otherwise rendered over the selection outlinem, Maybe there is an other solution
paddingTop: '2px',
}),
editPane: css({
flexDirection: 'column',

View File

@ -1,6 +1,5 @@
import { useMemo } from 'react';
import { SceneObjectBase } from '@grafana/scenes';
import { Input, TextArea } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@ -8,13 +7,14 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan
import { DashboardScene } from '../scene/DashboardScene';
import { useLayoutCategory } from '../scene/layouts-shared/DashboardLayoutSelector';
import { EditableDashboardElement } from '../scene/types';
import { getDashboardSceneFor } from '../utils/utils';
export class DashboardEditPaneBehavior extends SceneObjectBase implements EditableDashboardElement {
export class DashboardEditableElement implements EditableDashboardElement {
public isEditableDashboardElement: true = true;
public constructor(private dashboard: DashboardScene) {}
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
const dashboard = getDashboardSceneFor(this);
const dashboard = this.dashboard;
// When layout changes we need to update options list
const { body } = dashboard.useState();

View File

@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { sceneGraph, SceneObjectBase, VizPanel } from '@grafana/scenes';
import { sceneGraph, VizPanel } from '@grafana/scenes';
import { Button } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { getVisualizationOptions2 } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions';
@ -14,21 +15,13 @@ import {
import { EditableDashboardElement, isDashboardLayoutItem } from '../scene/types';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
export class VizPanelEditPaneBehavior extends SceneObjectBase implements EditableDashboardElement {
export class VizPanelEditableElement implements EditableDashboardElement {
public isEditableDashboardElement: true = true;
private getPanel(): VizPanel {
const panel = this.parent;
if (!(panel instanceof VizPanel)) {
throw new Error('VizPanelEditPaneBehavior must have a VizPanel parent');
}
return panel;
}
public constructor(private panel: VizPanel) {}
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
const panel = this.getPanel();
const panel = this.panel;
const layoutElement = panel.parent!;
const panelOptions = useMemo(() => {
@ -108,22 +101,18 @@ export class VizPanelEditPaneBehavior extends SceneObjectBase implements Editabl
}
public onDelete = () => {
const layout = dashboardSceneGraph.getLayoutManagerFor(this);
layout.removePanel(this.getPanel());
const layout = dashboardSceneGraph.getLayoutManagerFor(this.panel);
layout.removePanel(this.panel);
};
public renderActions(): React.ReactNode {
return (
<>
<Button size="sm" variant="secondary">
Edit
</Button>
<Button size="sm" variant="secondary">
Copy
</Button>
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete}>
Delete
<Trans i18nKey="panel.header-menu.edit">Edit</Trans>
</Button>
<Button size="sm" variant="secondary" icon="copy" />
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete} icon="trash-alt" />
</>
);
}

View File

@ -0,0 +1,31 @@
import { useMemo } from 'react';
import { SceneObject, VizPanel } from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene';
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types';
import { DashboardEditableElement } from './DashboardEditableElement';
import { VizPanelEditableElement } from './VizPanelEditableElement';
export function useEditableElement(sceneObj: SceneObject | undefined): EditableDashboardElement | undefined {
return useMemo(() => {
if (!sceneObj) {
return undefined;
}
if (isEditableDashboardElement(sceneObj)) {
return sceneObj;
}
if (sceneObj instanceof VizPanel) {
return new VizPanelEditableElement(sceneObj);
}
if (sceneObj instanceof DashboardScene) {
return new DashboardEditableElement(sceneObj);
}
return undefined;
}, [sceneObj]);
}

View File

@ -188,7 +188,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
body: state.body ?? DefaultGridLayoutManager.fromVizPanels(),
links: state.links ?? [],
...state,
editPane: new DashboardEditPane({}),
editPane: new DashboardEditPane(),
});
this._scopesFacade = getClosestScopesFacade(this);
@ -378,6 +378,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
dashboard: new DashboardModel(version.data),
meta: this.state.meta,
};
const dashScene = transformSaveModelToScene(dashboardDTO);
const newState = sceneUtils.cloneSceneObjectState(dashScene.state);
newState.version = versionRsp.version;

View File

@ -4,7 +4,7 @@ import { useMemo, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { SceneObjectState, SceneObjectBase, SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { Button, Icon, Input, RadioButtonGroup, Switch, useStyles2 } from '@grafana/ui';
import { Button, Icon, Input, RadioButtonGroup, Switch, useElementSelection, useStyles2 } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@ -72,15 +72,8 @@ export class RowItem extends SceneObjectBase<RowItemState> implements LayoutPare
public renderActions(): React.ReactNode {
return (
<>
<Button size="sm" variant="secondary">
Copy
</Button>
<Button size="sm" variant="primary" onClick={this.onAddPanel} fill="outline">
Add panel
</Button>
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete}>
Delete
</Button>
<Button size="sm" variant="secondary" icon="copy" />
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete} icon="trash-alt" />
</>
);
}
@ -97,16 +90,10 @@ export class RowItem extends SceneObjectBase<RowItemState> implements LayoutPare
this.setState({ isCollapsed: !this.state.isCollapsed });
};
public onAddPanel = () => {
const vizPanel = getDefaultVizPanel();
public onAddPanel = (vizPanel = getDefaultVizPanel()) => {
this.state.layout.addPanel(vizPanel);
};
public onEdit = () => {
const dashboard = getDashboardSceneFor(this);
dashboard.state.editPane.selectObject(this);
};
public static Component = ({ model }: SceneComponentProps<RowItem>) => {
const { layout, title, isCollapsed, height = 'expand' } = model.useState();
const { isEditing } = getDashboardSceneFor(model).useState();
@ -114,10 +101,16 @@ export class RowItem extends SceneObjectBase<RowItemState> implements LayoutPare
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
const ref = useRef<HTMLDivElement>(null);
const shouldGrow = !isCollapsed && height === 'expand';
const { isSelected, onSelect } = useElementSelection(model.state.key);
return (
<div
className={cx(styles.wrapper, isCollapsed && styles.wrapperCollapsed, shouldGrow && styles.wrapperGrow)}
className={cx(
styles.wrapper,
isCollapsed && styles.wrapperCollapsed,
shouldGrow && styles.wrapperGrow,
isSelected && 'dashboard-selected-element'
)}
ref={ref}
>
<div className={styles.rowHeader}>
@ -132,7 +125,9 @@ export class RowItem extends SceneObjectBase<RowItemState> implements LayoutPare
{titleInterpolated}
</span>
</button>
{isEditing && <Button icon="pen" variant="secondary" size="sm" fill="text" onClick={() => model.onEdit()} />}
{isEditing && (
<Button icon="pen" variant="secondary" size="sm" fill="text" onPointerDown={(evt) => onSelect?.(evt)} />
)}
</div>
{!isCollapsed && <layout.Component model={layout} />}
</div>

View File

@ -1,9 +1,10 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { DashboardScene } from '../DashboardScene';
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { DashboardLayoutManager, LayoutRegistryItem } from '../types';
@ -18,7 +19,22 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
public editModeChanged(isEditing: boolean): void {}
public addPanel(vizPanel: VizPanel): void {}
public addPanel(vizPanel: VizPanel): void {
// Try to add new panels to the selected row
const selectedObject = this.getSelectedObject();
if (selectedObject instanceof RowItem) {
return selectedObject.onAddPanel(vizPanel);
}
// If we don't have selected row add it to the first row
if (this.state.rows.length > 0) {
return this.state.rows[0].onAddPanel(vizPanel);
}
// Otherwise fallback to adding a new row and a panel
this.addNewRow();
this.state.rows[this.state.rows.length - 1].onAddPanel(vizPanel);
}
public addNewRow(): void {
this.setState({
@ -67,6 +83,10 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
return RowsLayoutManager.getDescriptor();
}
public getSelectedObject() {
return sceneGraph.getAncestor(this, DashboardScene).state.editPane.state.selectedObject?.resolve();
}
public static getDescriptor(): LayoutRegistryItem {
return {
name: 'Rows',
@ -106,7 +126,7 @@ function getStyles(theme: GrafanaTheme2) {
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
height: '100%',
flexGrow: 1,
width: '100%',
}),
};

View File

@ -27,7 +27,6 @@ import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { DashboardDTO, DashboardDataDTO } from 'app/types';
import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior';
import { DashboardEditPaneBehavior } from '../edit-pane/DashboardEditPaneBehavior';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls';
@ -224,7 +223,6 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
new behaviors.SceneQueryController(),
registerDashboardMacro,
registerPanelInteractionsReporter,
new DashboardEditPaneBehavior({}),
new behaviors.LiveNowTimer({ enabled: oldModel.liveNow }),
preserveDashboardSceneStateInLocalStorage,
addPanelsOnLoadBehavior,
@ -238,11 +236,6 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
version: oldModel.version,
}),
];
if (config.featureToggles.dashboardNewLayouts) {
behaviorList.push(new DashboardEditPaneBehavior({}));
}
const dashboardScene = new DashboardScene({
description: oldModel.description,
editable: oldModel.editable,

View File

@ -176,7 +176,7 @@ describe('transformSceneToSaveModelSchemaV2', () => {
}),
}),
meta: {},
editPane: new DashboardEditPane({}),
editPane: new DashboardEditPane(),
$behaviors: [
new behaviors.CursorSync({
sync: DashboardCursorSyncV1.Crosshair,