From f79ce08e5090fa33e112a08bfcc1cc94b6d8cff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 27 Feb 2025 13:42:22 +0100 Subject: [PATCH] Dashboard: Outline using EditableElement interface (#101076) --- .../edit-pane/DashboardEditPane.tsx | 9 +- .../edit-pane/DashboardEditPaneSplitter.tsx | 60 +++---- .../edit-pane/DashboardEditableElement.tsx | 7 +- .../edit-pane/DashboardOutline.tsx | 159 ++++++++++++++++++ .../edit-pane/ElementEditPane.tsx | 3 +- .../edit-pane/ElementSelection.ts | 23 +-- .../MultiSelectedObjectsEditableElement.tsx | 8 +- .../MultiSelectedVizPanelsEditableElement.tsx | 8 +- .../edit-pane/VizPanelEditableElement.tsx | 11 +- .../dashboard-scene/edit-pane/shared.ts | 50 ++++++ .../SceneGridRowEditableElement.tsx | 149 ++++++++++++++++ .../scene/layout-rows/RowItem.tsx | 7 +- .../scene/layout-rows/RowItems.tsx | 7 +- .../scene/layout-tabs/TabItem.tsx | 7 +- .../scene/layout-tabs/TabItems.tsx | 7 +- .../scene/types/EditableDashboardElement.ts | 13 +- .../MultiSelectedEditableDashboardElement.ts | 8 +- public/locales/en-US/grafana.json | 25 ++- public/locales/pseudo-LOCALE/grafana.json | 25 ++- 19 files changed, 509 insertions(+), 77 deletions(-) create mode 100644 public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx create mode 100644 public/app/features/dashboard-scene/scene/layout-default/SceneGridRowEditableElement.tsx diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index fbaf5aa5260..51cd262b24a 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -18,6 +18,7 @@ import { isInCloneChain } from '../utils/clone'; import { getDashboardSceneFor } from '../utils/utils'; import { DashboardAddPane } from './DashboardAddPane'; +import { DashboardOutline } from './DashboardOutline'; import { ElementEditPane } from './ElementEditPane'; import { ElementSelection } from './ElementSelection'; import { useEditableElement } from './useEditableElement'; @@ -181,6 +182,8 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla return null; } + const { typeId } = editableElement.getEditableElementInfo(); + if (isCollapsed) { return ( <> @@ -197,7 +200,7 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla {openOverlay && ( - + )} @@ -225,8 +228,8 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla
{tab === 'add' && } - {tab === 'configure' && } - {tab === 'outline' &&
} + {tab === 'configure' && } + {tab === 'outline' && }
); diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx index b2fc279ccde..f51540115c5 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx @@ -73,38 +73,40 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls return (
-
{ - if (evt.shiftKey) { - return; - } + +
{ + if (evt.shiftKey) { + return; + } - editPane.clearSelection(); - }} - > - -
{controls}
-
-
- {body} + editPane.clearSelection(); + }} + > + +
{controls}
+
+
+ {body} +
-
- {isEditing && ( - <> -
-
- 0} - /> -
- - )} + {isEditing && ( + <> +
+
+ 0} + /> +
+ + )} +
); } diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx index 4699e8c1c3f..77255075393 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx @@ -7,14 +7,17 @@ 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/EditableDashboardElement'; +import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement'; export class DashboardEditableElement implements EditableDashboardElement { public readonly isEditableDashboardElement = true; - public readonly typeName = 'Dashboard'; public constructor(private dashboard: DashboardScene) {} + public getEditableElementInfo(): EditableDashboardElementInfo { + return { typeId: 'dashboard', icon: 'apps', name: t('dashboard.edit-pane.elements.dashboard', 'Dashboard') }; + } + public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { const dashboard = this.dashboard; diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx new file mode 100644 index 00000000000..4f302a638fd --- /dev/null +++ b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx @@ -0,0 +1,159 @@ +import { css, cx } from '@emotion/css'; +import { useMemo, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { SceneObject, VizPanel } from '@grafana/scenes'; +import { Box, Icon, IconButton, Stack, Text, useElementSelection, useStyles2 } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; +import { isInCloneChain } from '../utils/clone'; +import { getDashboardSceneFor } from '../utils/utils'; + +import { DashboardEditPane } from './DashboardEditPane'; +import { getEditableElementFor, hasEditableElement } from './shared'; + +export interface Props { + editPane: DashboardEditPane; +} + +export function DashboardOutline({ editPane }: Props) { + const dashboard = getDashboardSceneFor(editPane); + + return ( + + + + ); +} + +function DashboardOutlineNode({ sceneObject, expandable }: { sceneObject: SceneObject; expandable: boolean }) { + const [isExpanded, setIsExpanded] = useState(true); + const { key } = sceneObject.useState(); + const styles = useStyles2(getStyles); + const { isSelected, onSelect } = useElementSelection(key); + const isCloned = useMemo(() => isInCloneChain(key!), [key]); + const editableElement = useMemo(() => getEditableElementFor(sceneObject)!, [sceneObject]); + + const children = collectEditableElementChildren(sceneObject); + const elementInfo = editableElement.getEditableElementInfo(); + + return ( + <> + + {expandable && ( + setIsExpanded(!isExpanded)} + aria-label={ + isExpanded + ? t('dashboard.outline.tree.item.collapse', 'Collapse item') + : t('dashboard.outline.tree.item.expand', 'Expand item') + } + /> + )} + + + {expandable && isExpanded && ( +
+ {children.length > 0 ? ( + children.map((child) => ( + + )) + ) : ( + + (empty) + + )} +
+ )} + + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + container: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + marginLeft: theme.spacing(1), + paddingLeft: theme.spacing(1.5), + borderLeft: `1px solid ${theme.colors.border.medium}`, + }), + nodeButton: css({ + boxShadow: 'none', + border: 'none', + background: 'transparent', + padding: theme.spacing(0.25, 1), + borderRadius: theme.shape.radius.default, + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + overflow: 'hidden', + '&:hover': { + backgroundColor: theme.colors.action.hover, + }, + '> span': { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + }), + nodeButtonSelected: css({ + color: theme.colors.primary.text, + }), + nodeButtonClone: css({ + color: theme.colors.text.secondary, + cursor: 'not-allowed', + }), + }; +} + +interface EditableElementConfig { + sceneObject: SceneObject; + expandable: boolean; +} + +function collectEditableElementChildren( + sceneObject: SceneObject, + children: EditableElementConfig[] = [] +): EditableElementConfig[] { + sceneObject.forEachChild((child) => { + if (child instanceof DashboardGridItem) { + // DashboardGridItem is a special case as it can contain repeated panels + // In this case, we want to show the repeated panels as separate items, otherwise show the body panel + if (child.state.repeatedPanels?.length) { + children.push(...child.state.repeatedPanels.map((panel) => ({ sceneObject: panel, expandable: false }))); + } else { + children.push({ sceneObject: child.state.body, expandable: false }); + } + } else if (child instanceof VizPanel) { + children.push({ sceneObject: child, expandable: false }); + } else if (hasEditableElement(child)) { + children.push({ sceneObject: child, expandable: true }); + } else { + collectEditableElementChildren(child, children); + } + }); + + return children; +} diff --git a/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx index 16adfe8004a..47cd84ade4b 100644 --- a/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx @@ -14,13 +14,14 @@ export interface Props { export function ElementEditPane({ element }: Props) { const categories = element.useEditPaneOptions ? element.useEditPaneOptions() : []; const styles = useStyles2(getStyles); + const elementInfo = element.getEditableElementInfo(); return ( {element.renderActions && ( diff --git a/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts b/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts index cecd28020cf..2141781408d 100644 --- a/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts +++ b/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts @@ -1,15 +1,14 @@ import { SceneObject, SceneObjectRef, VizPanel } from '@grafana/scenes'; import { ElementSelectionContextItem } from '@grafana/ui'; -import { DashboardScene } from '../scene/DashboardScene'; import { isBulkActionElement } from '../scene/types/BulkActionElement'; import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types/EditableDashboardElement'; import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement'; -import { DashboardEditableElement } from './DashboardEditableElement'; import { MultiSelectedObjectsEditableElement } from './MultiSelectedObjectsEditableElement'; import { MultiSelectedVizPanelsEditableElement } from './MultiSelectedVizPanelsEditableElement'; import { VizPanelEditableElement } from './VizPanelEditableElement'; +import { getEditableElementFor } from './shared'; export class ElementSelection { private selectedObjects?: Map>; @@ -121,24 +120,7 @@ export class ElementSelection { private createSingleSelectedElement(): EditableDashboardElement | undefined { const sceneObj = this.selectedObjects?.values().next().value?.resolve(); - - 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; + return getEditableElementFor(sceneObj); } private createMultiSelectedElement(): MultiSelectedEditableDashboardElement | undefined { @@ -161,6 +143,7 @@ export class ElementSelection { } const bulkActionElements = []; + for (const sceneObject of sceneObjects) { if (sceneObject instanceof VizPanel) { const editableElement = new VizPanelEditableElement(sceneObject); diff --git a/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx index 3278deb3a17..a55b5f0d8c9 100644 --- a/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx @@ -2,20 +2,24 @@ import { ReactNode } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { Stack, Text, Button } from '@grafana/ui'; -import { Trans } from 'app/core/internationalization'; +import { t, Trans } from 'app/core/internationalization'; import { BulkActionElement } from '../scene/types/BulkActionElement'; +import { EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement'; import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement'; export class MultiSelectedObjectsEditableElement implements MultiSelectedEditableDashboardElement { public readonly isMultiSelectedEditableDashboardElement = true; - public readonly typeName = 'Objects'; public readonly key: string; constructor(private _elements: BulkActionElement[]) { this.key = uuidv4(); } + public getEditableElementInfo(): EditableDashboardElementInfo { + return { name: t('dashboard.edit-pane.elements.objects', 'Objects'), typeId: 'objects', icon: 'folder' }; + } + public renderActions(): ReactNode { return ( diff --git a/public/app/features/dashboard-scene/edit-pane/MultiSelectedVizPanelsEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/MultiSelectedVizPanelsEditableElement.tsx index 259ae9d91cc..afa29772adc 100644 --- a/public/app/features/dashboard-scene/edit-pane/MultiSelectedVizPanelsEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/MultiSelectedVizPanelsEditableElement.tsx @@ -3,20 +3,24 @@ import { v4 as uuidv4 } from 'uuid'; import { VizPanel } from '@grafana/scenes'; import { Button, Stack, Text } from '@grafana/ui'; -import { Trans } from 'app/core/internationalization'; +import { t, Trans } from 'app/core/internationalization'; +import { EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement'; import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; export class MultiSelectedVizPanelsEditableElement implements MultiSelectedEditableDashboardElement { public readonly isMultiSelectedEditableDashboardElement = true; - public readonly typeName = 'Panels'; public readonly key: string; constructor(private _panels: VizPanel[]) { this.key = uuidv4(); } + public getEditableElementInfo(): EditableDashboardElementInfo { + return { name: t('dashboard.edit-pane.elements.panels', 'Panels'), typeId: 'panels', icon: 'folder' }; + } + renderActions(): ReactNode { return ( diff --git a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx index 4d97803b8db..c20c61113fa 100644 --- a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx @@ -14,15 +14,22 @@ import { } from '../panel-edit/getPanelFrameOptions'; import { BulkActionElement } from '../scene/types/BulkActionElement'; import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem'; -import { EditableDashboardElement } from '../scene/types/EditableDashboardElement'; +import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; export class VizPanelEditableElement implements EditableDashboardElement, BulkActionElement { public readonly isEditableDashboardElement = true; - public readonly typeName = 'Panel'; public constructor(private panel: VizPanel) {} + public getEditableElementInfo(): EditableDashboardElementInfo { + return { + typeId: 'panel', + icon: 'chart-line', + name: sceneGraph.interpolate(this.panel, this.panel.state.title, undefined, 'text'), + }; + } + public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { const panel = this.panel; const layoutElement = panel.parent!; diff --git a/public/app/features/dashboard-scene/edit-pane/shared.ts b/public/app/features/dashboard-scene/edit-pane/shared.ts index 59ee48dde75..72c44d5dc2b 100644 --- a/public/app/features/dashboard-scene/edit-pane/shared.ts +++ b/public/app/features/dashboard-scene/edit-pane/shared.ts @@ -1,5 +1,55 @@ import { useSessionStorage } from 'react-use'; +import { SceneGridRow, SceneObject, VizPanel } from '@grafana/scenes'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { SceneGridRowEditableElement } from '../scene/layout-default/SceneGridRowEditableElement'; +import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types/EditableDashboardElement'; + +import { DashboardEditableElement } from './DashboardEditableElement'; +import { VizPanelEditableElement } from './VizPanelEditableElement'; + export function useEditPaneCollapsed() { return useSessionStorage('grafana.dashboards.edit-pane.isCollapsed', false); } + +export function getEditableElementFor(sceneObj: SceneObject | undefined): EditableDashboardElement | undefined { + if (!sceneObj) { + return undefined; + } + + if (isEditableDashboardElement(sceneObj)) { + return sceneObj; + } + + if (sceneObj instanceof VizPanel) { + return new VizPanelEditableElement(sceneObj); + } + + if (sceneObj instanceof SceneGridRow) { + return new SceneGridRowEditableElement(sceneObj); + } + + if (sceneObj instanceof DashboardScene) { + return new DashboardEditableElement(sceneObj); + } + + return undefined; +} + +export function hasEditableElement(sceneObj: SceneObject | undefined): boolean { + if (!sceneObj) { + return false; + } + + if ( + isEditableDashboardElement(sceneObj) || + sceneObj instanceof VizPanel || + sceneObj instanceof SceneGridRow || + sceneObj instanceof DashboardScene + ) { + return true; + } + + return false; +} diff --git a/public/app/features/dashboard-scene/scene/layout-default/SceneGridRowEditableElement.tsx b/public/app/features/dashboard-scene/scene/layout-default/SceneGridRowEditableElement.tsx new file mode 100644 index 00000000000..497b4087da9 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-default/SceneGridRowEditableElement.tsx @@ -0,0 +1,149 @@ +import { ReactNode, useMemo } from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { sceneGraph, SceneGridRow, VizPanel } from '@grafana/scenes'; +import { Alert, Button, Input, TextLink } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; +import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; +import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; +import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; +import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants'; +import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; + +import { getDashboardSceneFor, getLayoutManagerFor, getQueryRunnerFor } from '../../utils/utils'; +import { DashboardScene } from '../DashboardScene'; +import { BulkActionElement } from '../types/BulkActionElement'; +import { EditableDashboardElement, EditableDashboardElementInfo } from '../types/EditableDashboardElement'; + +import { DefaultGridLayoutManager } from './DefaultGridLayoutManager'; +import { RowRepeaterBehavior } from './RowRepeaterBehavior'; + +export class SceneGridRowEditableElement implements EditableDashboardElement, BulkActionElement { + public readonly isEditableDashboardElement = true; + + public constructor(private _row: SceneGridRow) {} + + public getEditableElementInfo(): EditableDashboardElementInfo { + return { + typeId: 'panel', + icon: 'line-alt', + name: sceneGraph.interpolate(this._row, this._row.state.title, undefined, 'text'), + }; + } + + public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { + const row = this._row; + + const rowOptions = useMemo(() => { + return new OptionsPaneCategoryDescriptor({ + title: t('dashboard.default-layout.row-options.title', 'Row options'), + id: 'row-options', + isOpenDefault: true, + }).addItem( + new OptionsPaneItemDescriptor({ + title: t('dashboard.default-layout.row-options.form.title', 'Title'), + render: () => , + }) + ); + }, [row]); + + const rowRepeatOptions = useMemo(() => { + const dashboard = getDashboardSceneFor(row); + + return new OptionsPaneCategoryDescriptor({ + title: t('dashboard.default-layout.row-options.repeat.title', 'Repeat options'), + id: 'row-repeat-options', + isOpenDefault: true, + }).addItem( + new OptionsPaneItemDescriptor({ + title: t('dashboard.default-layout.row-options.repeat.variable.title', 'Variable'), + render: () => , + }) + ); + }, [row]); + + return [rowOptions, rowRepeatOptions]; + } + + public onDelete() { + const layoutManager = getLayoutManagerFor(this._row); + + if (layoutManager instanceof DefaultGridLayoutManager) { + layoutManager.removeRow(this._row); + } + } + + public renderActions(): ReactNode { + return ( + <> +