Skip to content

Commit

Permalink
Dashboard: Outline using EditableElement interface (grafana#101076)
Browse files Browse the repository at this point in the history
  • Loading branch information
torkelo authored Feb 27, 2025
1 parent 8a988d6 commit f79ce08
Show file tree
Hide file tree
Showing 19 changed files with 509 additions and 77 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -181,6 +182,8 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla
return null;
}

const { typeId } = editableElement.getEditableElementInfo();

if (isCollapsed) {
return (
<>
Expand All @@ -197,7 +200,7 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla

{openOverlay && (
<Resizable className={cx(styles.fixed, styles.container)} defaultSize={{ height: '100%', width: '20vw' }}>
<ElementEditPane element={editableElement} key={editableElement.typeName} />
<ElementEditPane element={editableElement} key={typeId} />
</Resizable>
)}
</>
Expand Down Expand Up @@ -225,8 +228,8 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla
</TabsBar>
<div className={styles.tabContent}>
{tab === 'add' && <DashboardAddPane editPane={editPane} />}
{tab === 'configure' && <ElementEditPane element={editableElement} key={editableElement.typeName} />}
{tab === 'outline' && <div />}
{tab === 'configure' && <ElementEditPane element={editableElement} key={typeId} />}
{tab === 'outline' && <DashboardOutline editPane={editPane} />}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,38 +73,40 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls

return (
<div {...containerProps} style={containerStyle}>
<div
{...primaryProps}
className={cx(primaryProps.className, styles.canvasWithSplitter)}
onPointerDown={(evt) => {
if (evt.shiftKey) {
return;
}
<ElementSelectionContext.Provider value={selectionContext}>
<div
{...primaryProps}
className={cx(primaryProps.className, styles.canvasWithSplitter)}
onPointerDown={(evt) => {
if (evt.shiftKey) {
return;
}

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}>
<ElementSelectionContext.Provider value={selectionContext}>{body}</ElementSelectionContext.Provider>
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}
</div>
</div>
</div>
</div>
{isEditing && (
<>
<div {...splitterProps} data-edit-pane-splitter={true} />
<div {...secondaryProps} className={cx(secondaryProps.className, styles.editPane)}>
<DashboardEditPaneRenderer
editPane={editPane}
isCollapsed={splitterState.collapsed}
onToggleCollapse={onToggleCollapse}
openOverlay={selectionContext.selected.length > 0}
/>
</div>
</>
)}
{isEditing && (
<>
<div {...splitterProps} data-edit-pane-splitter={true} />
<div {...secondaryProps} className={cx(secondaryProps.className, styles.editPane)}>
<DashboardEditPaneRenderer
editPane={editPane}
isCollapsed={splitterState.collapsed}
onToggleCollapse={onToggleCollapse}
openOverlay={selectionContext.selected.length > 0}
/>
</div>
</>
)}
</ElementSelectionContext.Provider>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
159 changes: 159 additions & 0 deletions public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box padding={1} gap={0.5} display="flex" direction="column">
<DashboardOutlineNode sceneObject={dashboard} expandable />
</Box>
);
}

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 (
<>
<Stack
direction="row"
gap={0.5}
alignItems="center"
role="presentation"
aria-expanded={expandable ? isExpanded : undefined}
aria-owns={expandable ? key : undefined}
>
{expandable && (
<IconButton
name={isExpanded ? 'angle-down' : 'angle-right'}
onClick={() => setIsExpanded(!isExpanded)}
aria-label={
isExpanded
? t('dashboard.outline.tree.item.collapse', 'Collapse item')
: t('dashboard.outline.tree.item.expand', 'Expand item')
}
/>
)}
<button
role="treeitem"
className={cx(styles.nodeButton, isCloned && styles.nodeButtonClone, isSelected && styles.nodeButtonSelected)}
onPointerDown={(evt) => onSelect?.(evt)}
>
<Icon name={elementInfo.icon} />
<span>{elementInfo.name}</span>
</button>
</Stack>
{expandable && isExpanded && (
<div className={styles.container} role="group">
{children.length > 0 ? (
children.map((child) => (
<DashboardOutlineNode
key={child.sceneObject.state.key}
sceneObject={child.sceneObject}
expandable={child.expandable}
/>
))
) : (
<Text element="p" color="secondary">
<Trans i18nKey="dashboard.outline.tree.item.empty">(empty)</Trans>
</Text>
)}
</div>
)}
</>
);
}

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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Stack direction="column" gap={0}>
{element.renderActions && (
<OptionsPaneCategory
id="selected-item"
title={element.typeName}
title={elementInfo.name}
isOpenDefault={true}
className={styles.noBorderTop}
>
Expand Down
23 changes: 3 additions & 20 deletions public/app/features/dashboard-scene/edit-pane/ElementSelection.ts
Original file line number Diff line number Diff line change
@@ -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<string, SceneObjectRef<SceneObject>>;
Expand Down Expand Up @@ -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 {
Expand All @@ -161,6 +143,7 @@ export class ElementSelection {
}

const bulkActionElements = [];

for (const sceneObject of sceneObjects) {
if (sceneObject instanceof VizPanel) {
const editableElement = new VizPanelEditableElement(sceneObject);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Stack direction="column">
Expand Down
Loading

0 comments on commit f79ce08

Please sign in to comment.