DashboardScene: Edit actions , undo/redo and repeat change detection (#104160)
* AutoGrid: Panel editor editingCompleted call * editing started as well * DashboardScene: Quick poc for undo/redo and repeat change detection * undo redo working * Update * Update * Update * update * Update * Update * Update * Update * Update * Update * Add panel title and description support * Add comment * Update * review updates * Update * Update * Update * Update translations * Update * fix * Update * fix test that is pretty bad/broken * Fix translation keys
This commit is contained in:
+1
-1
@@ -1588,7 +1588,7 @@ exports[`better eslint`] = {
|
||||
"public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingTimeRangeSize.tsx:5381": [
|
||||
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx:5381": [
|
||||
"public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.tsx:5381": [
|
||||
|
||||
@@ -81,6 +81,8 @@ export const availableIconsIndex = {
|
||||
compass: true,
|
||||
'compress-arrows': true,
|
||||
copy: true,
|
||||
'corner-up-left': true,
|
||||
'corner-up-right': true,
|
||||
'corner-down-right-alt': true,
|
||||
'create-dashboard': true,
|
||||
'credit-card': true,
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"unicons/compass",
|
||||
"unicons/copy",
|
||||
"unicons/corner-down-right-alt",
|
||||
"unicons/corner-up-left",
|
||||
"unicons/corner-up-right",
|
||||
"unicons/cube",
|
||||
"unicons/dashboard",
|
||||
"unicons/database",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { activateFullSceneTree } from '../utils/test-utils';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getDataSourceSrv: () => {
|
||||
return {
|
||||
getInstanceSettings: (uid: string) => ({}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
describe('DashboardEditPane', () => {
|
||||
it('Handles edit action events that adds objects', () => {
|
||||
const scene = buildTestScene();
|
||||
const editPane = scene.state.editPane;
|
||||
|
||||
scene.onCreateNewPanel();
|
||||
|
||||
expect(editPane.state.undoStack).toHaveLength(1);
|
||||
|
||||
// Should select object
|
||||
expect(editPane.getSelection()).toBeDefined();
|
||||
|
||||
editPane.undoAction();
|
||||
|
||||
expect(editPane.state.undoStack).toHaveLength(0);
|
||||
|
||||
// should clear selection
|
||||
expect(editPane.getSelection()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('when new action comes in clears redo stack', () => {
|
||||
const scene = buildTestScene();
|
||||
const editPane = scene.state.editPane;
|
||||
|
||||
scene.onCreateNewPanel();
|
||||
|
||||
editPane.undoAction();
|
||||
|
||||
expect(editPane.state.redoStack).toHaveLength(1);
|
||||
|
||||
scene.onCreateNewPanel();
|
||||
|
||||
expect(editPane.state.redoStack).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
function buildTestScene() {
|
||||
const scene = new DashboardScene({
|
||||
title: 'hello',
|
||||
uid: 'dash-1',
|
||||
description: 'hello description',
|
||||
tags: ['tag1', 'tag2'],
|
||||
editable: true,
|
||||
});
|
||||
|
||||
config.featureToggles.dashboardNewLayouts = true;
|
||||
|
||||
activateFullSceneTree(scene);
|
||||
|
||||
return scene;
|
||||
}
|
||||
@@ -1,48 +1,30 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Resizable } from 're-resizable';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, useTranslate } from '@grafana/i18n';
|
||||
import { SceneObjectState, SceneObjectBase, SceneObject, sceneGraph, VizPanel } from '@grafana/scenes';
|
||||
import {
|
||||
SceneObjectState,
|
||||
SceneObjectBase,
|
||||
SceneObject,
|
||||
sceneGraph,
|
||||
useSceneObjectState,
|
||||
VizPanel,
|
||||
} from '@grafana/scenes';
|
||||
import {
|
||||
clearButtonStyles,
|
||||
ElementSelectionContextItem,
|
||||
ElementSelectionContextState,
|
||||
ElementSelectionOnSelectOptions,
|
||||
ScrollContainer,
|
||||
ToolbarButton,
|
||||
useSplitter,
|
||||
useStyles2,
|
||||
Text,
|
||||
Icon,
|
||||
} from '@grafana/ui';
|
||||
|
||||
import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem';
|
||||
import { containsCloneKey, getLastKeyFromClone, isInCloneChain } from '../utils/clone';
|
||||
import { findEditPanel, getDashboardSceneFor } from '../utils/utils';
|
||||
|
||||
import { DashboardOutline } from './DashboardOutline';
|
||||
import { ElementEditPane } from './ElementEditPane';
|
||||
import { ElementSelection } from './ElementSelection';
|
||||
import {
|
||||
ConditionalRenderingChangedEvent,
|
||||
DashboardEditActionEvent,
|
||||
DashboardEditActionEventPayload,
|
||||
NewObjectAddedToCanvasEvent,
|
||||
ObjectRemovedFromCanvasEvent,
|
||||
ObjectsReorderedOnCanvasEvent,
|
||||
} from './shared';
|
||||
import { useEditableElement } from './useEditableElement';
|
||||
|
||||
export interface DashboardEditPaneState extends SceneObjectState {
|
||||
selection?: ElementSelection;
|
||||
selectionContext: ElementSelectionContextState;
|
||||
|
||||
undoStack: DashboardEditActionEventPayload[];
|
||||
redoStack: DashboardEditActionEventPayload[];
|
||||
}
|
||||
|
||||
export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
|
||||
@@ -54,6 +36,8 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
|
||||
onSelect: (item, options) => this.selectElement(item, options),
|
||||
onClear: () => this.clearSelection(),
|
||||
},
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
});
|
||||
|
||||
this.addActivationHandler(this.onActivate.bind(this));
|
||||
@@ -62,6 +46,12 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
|
||||
private onActivate() {
|
||||
const dashboard = getDashboardSceneFor(this);
|
||||
|
||||
this._subs.add(
|
||||
dashboard.subscribeToEvent(DashboardEditActionEvent, ({ payload }) => {
|
||||
this.handleEditAction(payload);
|
||||
})
|
||||
);
|
||||
|
||||
this._subs.add(
|
||||
dashboard.subscribeToEvent(NewObjectAddedToCanvasEvent, ({ payload }) => {
|
||||
this.newObjectAddedToCanvas(payload);
|
||||
@@ -87,6 +77,88 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles all edit actions
|
||||
* Adds to undo history and selects new object
|
||||
* @param payload
|
||||
*/
|
||||
private handleEditAction(action: DashboardEditActionEventPayload) {
|
||||
// Clear redo stack when user performs a new action
|
||||
// Otherwise things can get into very broken states
|
||||
if (this.state.redoStack.length > 0) {
|
||||
this.setState({ redoStack: [] });
|
||||
}
|
||||
|
||||
this.performAction(action);
|
||||
|
||||
this.setState({ undoStack: [...this.state.undoStack, action] });
|
||||
|
||||
// Notify repeaters that something changed
|
||||
if (action.source instanceof VizPanel) {
|
||||
const layoutElement = action.source.parent!;
|
||||
|
||||
if (isDashboardLayoutItem(layoutElement) && layoutElement.editingCompleted) {
|
||||
layoutElement.editingCompleted(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes last action from undo stack and adds it to redo stack.
|
||||
*/
|
||||
public undoAction() {
|
||||
const undoStack = this.state.undoStack.slice();
|
||||
const action = undoStack.pop();
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
|
||||
action.undo();
|
||||
|
||||
/**
|
||||
* Some edit actions also require clearing selection or selecting new objects
|
||||
*/
|
||||
if (action.addedObject) {
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
if (action.removedObject) {
|
||||
this.newObjectAddedToCanvas(action.removedObject);
|
||||
}
|
||||
|
||||
this.setState({ undoStack, redoStack: [...this.state.redoStack, action] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Some edit actions also require clearing selection or selecting new objects
|
||||
*/
|
||||
private performAction(action: DashboardEditActionEventPayload) {
|
||||
action.perform();
|
||||
|
||||
if (action.addedObject) {
|
||||
this.newObjectAddedToCanvas(action.addedObject);
|
||||
}
|
||||
|
||||
if (action.removedObject) {
|
||||
this.clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes last action from redo stack and adds it to undo stack.
|
||||
*/
|
||||
public redoAction() {
|
||||
const redoStack = this.state.redoStack.slice();
|
||||
const action = redoStack.pop();
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.performAction(action);
|
||||
|
||||
this.setState({ redoStack, undoStack: [...this.state.undoStack, action] });
|
||||
}
|
||||
|
||||
public enableSelection() {
|
||||
// Enable element selection
|
||||
this.setState({ selectionContext: { ...this.state.selectionContext, enabled: true } });
|
||||
@@ -186,190 +258,3 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
|
||||
this.state.selection!.markAsNewElement();
|
||||
}
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
editPane: DashboardEditPane;
|
||||
isCollapsed: boolean;
|
||||
openOverlay?: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Making the EditPane rendering completely standalone (not using editPane.Component) in order to pass custom react props
|
||||
*/
|
||||
export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleCollapse, openOverlay }: Props) {
|
||||
const { selection } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true });
|
||||
const styles = useStyles2(getStyles);
|
||||
const clearButton = useStyles2(clearButtonStyles);
|
||||
const editableElement = useEditableElement(selection, editPane);
|
||||
const selectedObject = selection?.getFirstObject();
|
||||
const isNewElement = selection?.isNewElement() ?? false;
|
||||
const [outlineCollapsed, setOutlineCollapsed] = useLocalStorage(
|
||||
'grafana.dashboard.edit-pane.outline.collapsed',
|
||||
true
|
||||
);
|
||||
const [outlinePaneSize = 0.4, setOutlinePaneSize] = useLocalStorage('grafana.dashboard.edit-pane.outline.size', 0.4);
|
||||
|
||||
// splitter for template and payload editor
|
||||
const splitter = useSplitter({
|
||||
direction: 'column',
|
||||
handleSize: 'sm',
|
||||
// if Grafana Alertmanager, split 50/50, otherwise 100/0 because there is no payload editor
|
||||
initialSize: 1 - outlinePaneSize,
|
||||
dragPosition: 'middle',
|
||||
onSizeChanged: (size) => {
|
||||
setOutlinePaneSize(1 - size);
|
||||
},
|
||||
});
|
||||
const { t } = useTranslate();
|
||||
|
||||
if (!editableElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.expandOptionsWrapper}>
|
||||
<ToolbarButton
|
||||
tooltip={t('dashboard.edit-pane.open', 'Open options pane')}
|
||||
icon="arrow-to-right"
|
||||
onClick={onToggleCollapse}
|
||||
variant="canvas"
|
||||
narrow={true}
|
||||
className={styles.rotate180}
|
||||
aria-label={t('dashboard.edit-pane.open', 'Open options pane')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{openOverlay && (
|
||||
<Resizable className={styles.overlayWrapper} defaultSize={{ height: '100%', width: '300px' }}>
|
||||
<ElementEditPane
|
||||
element={editableElement}
|
||||
key={selectedObject?.state.key}
|
||||
editPane={editPane}
|
||||
isNewElement={isNewElement}
|
||||
/>
|
||||
</Resizable>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (outlineCollapsed) {
|
||||
splitter.primaryProps.style.flexGrow = 1;
|
||||
splitter.primaryProps.style.minHeight = 'unset';
|
||||
splitter.secondaryProps.style.flexGrow = 0;
|
||||
splitter.secondaryProps.style.minHeight = 'min-content';
|
||||
} else {
|
||||
splitter.primaryProps.style.minHeight = 'unset';
|
||||
splitter.secondaryProps.style.minHeight = 'unset';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div {...splitter.containerProps}>
|
||||
<div {...splitter.primaryProps} className={cx(splitter.primaryProps.className, styles.paneContent)}>
|
||||
<ElementEditPane
|
||||
element={editableElement}
|
||||
key={selectedObject?.state.key}
|
||||
editPane={editPane}
|
||||
isNewElement={isNewElement}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
{...splitter.splitterProps}
|
||||
className={cx(splitter.splitterProps.className, styles.splitter)}
|
||||
data-edit-pane-splitter={true}
|
||||
/>
|
||||
<div {...splitter.secondaryProps} className={cx(splitter.primaryProps.className, styles.paneContent)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOutlineCollapsed(!outlineCollapsed)}
|
||||
className={cx(clearButton, styles.outlineCollapseButton)}
|
||||
data-testid={selectors.components.PanelEditor.Outline.section}
|
||||
>
|
||||
<Text weight="medium">
|
||||
<Trans i18nKey="dashboard-scene.dashboard-edit-pane-renderer.outline">Outline</Trans>
|
||||
</Text>
|
||||
<Icon name={outlineCollapsed ? 'angle-up' : 'angle-down'} />
|
||||
</button>
|
||||
{!outlineCollapsed && (
|
||||
<div className={styles.outlineContainer}>
|
||||
<ScrollContainer showScrollIndicators={true}>
|
||||
<DashboardOutline editPane={editPane} />
|
||||
</ScrollContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
wrapper: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: '1 1 0',
|
||||
marginTop: theme.spacing(2),
|
||||
borderLeft: `1px solid ${theme.colors.border.weak}`,
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
background: theme.colors.background.primary,
|
||||
borderTopLeftRadius: theme.shape.radius.default,
|
||||
}),
|
||||
overlayWrapper: css({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
top: theme.spacing(2),
|
||||
position: 'absolute !important' as 'absolute',
|
||||
background: theme.colors.background.primary,
|
||||
borderLeft: `1px solid ${theme.colors.border.weak}`,
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
boxShadow: theme.shadows.z3,
|
||||
zIndex: theme.zIndex.navbarFixed,
|
||||
flexGrow: 1,
|
||||
}),
|
||||
paneContent: css({
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
rotate180: css({
|
||||
rotate: '180deg',
|
||||
}),
|
||||
tabsbar: css({
|
||||
padding: theme.spacing(0, 1),
|
||||
margin: theme.spacing(0.5, 0),
|
||||
}),
|
||||
expandOptionsWrapper: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: theme.spacing(2, 1, 2, 0),
|
||||
}),
|
||||
splitter: css({
|
||||
'&:after': {
|
||||
display: 'none',
|
||||
},
|
||||
}),
|
||||
outlineCollapseButton: css({
|
||||
display: 'flex',
|
||||
padding: theme.spacing(0.5, 2),
|
||||
gap: theme.spacing(1),
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
background: theme.colors.background.secondary,
|
||||
|
||||
'&:hover': {
|
||||
background: theme.colors.action.hover,
|
||||
},
|
||||
}),
|
||||
outlineContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Resizable } from 're-resizable';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, useTranslate } from '@grafana/i18n';
|
||||
import { useSceneObjectState } from '@grafana/scenes';
|
||||
import { useStyles2, useSplitter, ToolbarButton, ScrollContainer, Text, Icon, clearButtonStyles } from '@grafana/ui';
|
||||
|
||||
import { DashboardEditPane } from './DashboardEditPane';
|
||||
import { DashboardOutline } from './DashboardOutline';
|
||||
import { ElementEditPane } from './ElementEditPane';
|
||||
import { useEditableElement } from './useEditableElement';
|
||||
|
||||
export interface Props {
|
||||
editPane: DashboardEditPane;
|
||||
isCollapsed: boolean;
|
||||
openOverlay?: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Making the EditPane rendering completely standalone (not using editPane.Component) in order to pass custom react props
|
||||
*/
|
||||
export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleCollapse, openOverlay }: Props) {
|
||||
const { selection } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true });
|
||||
const styles = useStyles2(getStyles);
|
||||
const clearButton = useStyles2(clearButtonStyles);
|
||||
const editableElement = useEditableElement(selection, editPane);
|
||||
const selectedObject = selection?.getFirstObject();
|
||||
const { t } = useTranslate();
|
||||
const isNewElement = selection?.isNewElement() ?? false;
|
||||
const [outlineCollapsed, setOutlineCollapsed] = useLocalStorage(
|
||||
'grafana.dashboard.edit-pane.outline.collapsed',
|
||||
true
|
||||
);
|
||||
const [outlinePaneSize = 0.4, setOutlinePaneSize] = useLocalStorage('grafana.dashboard.edit-pane.outline.size', 0.4);
|
||||
|
||||
// splitter for template and payload editor
|
||||
const splitter = useSplitter({
|
||||
direction: 'column',
|
||||
handleSize: 'sm',
|
||||
// if Grafana Alertmanager, split 50/50, otherwise 100/0 because there is no payload editor
|
||||
initialSize: 1 - outlinePaneSize,
|
||||
dragPosition: 'middle',
|
||||
onSizeChanged: (size) => {
|
||||
setOutlinePaneSize(1 - size);
|
||||
},
|
||||
});
|
||||
|
||||
if (!editableElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.expandOptionsWrapper}>
|
||||
<ToolbarButton
|
||||
tooltip={t('dashboard.edit-pane.open', 'Open options pane')}
|
||||
icon="arrow-to-right"
|
||||
onClick={onToggleCollapse}
|
||||
variant="canvas"
|
||||
narrow={true}
|
||||
className={styles.rotate180}
|
||||
aria-label={t('dashboard.edit-pane.open', 'Open options pane')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{openOverlay && (
|
||||
<Resizable className={styles.overlayWrapper} defaultSize={{ height: '100%', width: '300px' }}>
|
||||
<ElementEditPane
|
||||
element={editableElement}
|
||||
key={selectedObject?.state.key}
|
||||
editPane={editPane}
|
||||
isNewElement={isNewElement}
|
||||
/>
|
||||
</Resizable>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (outlineCollapsed) {
|
||||
splitter.primaryProps.style.flexGrow = 1;
|
||||
splitter.primaryProps.style.minHeight = 'unset';
|
||||
splitter.secondaryProps.style.flexGrow = 0;
|
||||
splitter.secondaryProps.style.minHeight = 'min-content';
|
||||
} else {
|
||||
splitter.primaryProps.style.minHeight = 'unset';
|
||||
splitter.secondaryProps.style.minHeight = 'unset';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div {...splitter.containerProps}>
|
||||
<div {...splitter.primaryProps} className={cx(splitter.primaryProps.className, styles.paneContent)}>
|
||||
<ElementEditPane
|
||||
element={editableElement}
|
||||
key={selectedObject?.state.key}
|
||||
editPane={editPane}
|
||||
isNewElement={isNewElement}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
{...splitter.splitterProps}
|
||||
className={cx(splitter.splitterProps.className, styles.splitter)}
|
||||
data-edit-pane-splitter={true}
|
||||
/>
|
||||
<div {...splitter.secondaryProps} className={cx(splitter.primaryProps.className, styles.paneContent)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOutlineCollapsed(!outlineCollapsed)}
|
||||
className={cx(clearButton, styles.outlineCollapseButton)}
|
||||
data-testid={selectors.components.PanelEditor.Outline.section}
|
||||
>
|
||||
<Text weight="medium">
|
||||
<Trans i18nKey="dashboard-scene.dashboard-edit-pane-renderer.outline">Outline</Trans>
|
||||
</Text>
|
||||
<Icon name={outlineCollapsed ? 'angle-up' : 'angle-down'} />
|
||||
</button>
|
||||
{!outlineCollapsed && (
|
||||
<div className={styles.outlineContainer}>
|
||||
<ScrollContainer showScrollIndicators={true}>
|
||||
<DashboardOutline editPane={editPane} />
|
||||
</ScrollContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
wrapper: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: '1 1 0',
|
||||
marginTop: theme.spacing(2),
|
||||
borderLeft: `1px solid ${theme.colors.border.weak}`,
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
background: theme.colors.background.primary,
|
||||
borderTopLeftRadius: theme.shape.radius.default,
|
||||
}),
|
||||
overlayWrapper: css({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
top: theme.spacing(2),
|
||||
position: 'absolute !important' as 'absolute',
|
||||
background: theme.colors.background.primary,
|
||||
borderLeft: `1px solid ${theme.colors.border.weak}`,
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
boxShadow: theme.shadows.z3,
|
||||
zIndex: theme.zIndex.navbarFixed,
|
||||
flexGrow: 1,
|
||||
}),
|
||||
paneContent: css({
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
rotate180: css({
|
||||
rotate: '180deg',
|
||||
}),
|
||||
tabsbar: css({
|
||||
padding: theme.spacing(0, 1),
|
||||
margin: theme.spacing(0.5, 0),
|
||||
}),
|
||||
expandOptionsWrapper: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: theme.spacing(2, 1, 2, 0),
|
||||
}),
|
||||
splitter: css({
|
||||
'&:after': {
|
||||
display: 'none',
|
||||
},
|
||||
}),
|
||||
outlineCollapseButton: css({
|
||||
display: 'flex',
|
||||
padding: theme.spacing(0.5, 2),
|
||||
gap: theme.spacing(1),
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
background: theme.colors.background.secondary,
|
||||
|
||||
'&:hover': {
|
||||
background: theme.colors.action.hover,
|
||||
},
|
||||
}),
|
||||
outlineContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { useSnappingSplitter } from '../panel-edit/splitter/useSnappingSplitter'
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { NavToolbarActions } from '../scene/NavToolbarActions';
|
||||
|
||||
import { DashboardEditPaneRenderer } from './DashboardEditPane';
|
||||
import { DashboardEditPaneRenderer } from './DashboardEditPaneRenderer';
|
||||
import { useEditPaneCollapsed } from './shared';
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
PanelBackgroundSwitch,
|
||||
PanelDescriptionTextArea,
|
||||
PanelFrameTitleInput,
|
||||
setPanelTitle,
|
||||
editPanelTitleAction,
|
||||
} from '../panel-edit/getPanelFrameOptions';
|
||||
import { AutoGridItem } from '../scene/layout-auto-grid/AutoGridItem';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
@@ -115,7 +115,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
|
||||
}
|
||||
|
||||
public onChangeName(name: string) {
|
||||
setPanelTitle(this.panel, name);
|
||||
editPanelTitleAction(this.panel, name);
|
||||
}
|
||||
|
||||
public createMultiSelectedElement(items: VizPanelEditableElement[]) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useSessionStorage } from 'react-use';
|
||||
|
||||
import { BusEventWithPayload } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n/internal';
|
||||
import { LocalValueVariable, SceneGridRow, SceneObject, SceneVariableSet, VizPanel } from '@grafana/scenes';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
@@ -69,3 +70,79 @@ export class ObjectsReorderedOnCanvasEvent extends BusEventWithPayload<SceneObje
|
||||
export class ConditionalRenderingChangedEvent extends BusEventWithPayload<SceneObject> {
|
||||
static type = 'conditional-rendering-changed';
|
||||
}
|
||||
|
||||
export interface DashboardEditActionEventPayload {
|
||||
removedObject?: SceneObject;
|
||||
addedObject?: SceneObject;
|
||||
source: SceneObject;
|
||||
description?: string;
|
||||
perform: () => void;
|
||||
undo: () => void;
|
||||
}
|
||||
|
||||
export class DashboardEditActionEvent extends BusEventWithPayload<DashboardEditActionEventPayload> {
|
||||
static type = 'dashboard-edit-action';
|
||||
}
|
||||
|
||||
export interface AddElementActionHelperProps {
|
||||
addedObject: SceneObject;
|
||||
source: SceneObject;
|
||||
perform: () => void;
|
||||
undo: () => void;
|
||||
}
|
||||
|
||||
export interface RemoveElementActionHelperProps {
|
||||
removedObject: SceneObject;
|
||||
source: SceneObject;
|
||||
perform: () => void;
|
||||
undo: () => void;
|
||||
}
|
||||
|
||||
export const dashboardEditActions = {
|
||||
/**
|
||||
* Registers and peforms an edit action
|
||||
*/
|
||||
edit: function (props: DashboardEditActionEventPayload) {
|
||||
props.source.publishEvent(new DashboardEditActionEvent(props), true);
|
||||
},
|
||||
/**
|
||||
* Helper for makeEdit that adds elements
|
||||
*/
|
||||
addElement: function (props: AddElementActionHelperProps) {
|
||||
const { addedObject, source, perform, undo } = props;
|
||||
|
||||
const element = getEditableElementFor(addedObject);
|
||||
if (!element) {
|
||||
throw new Error('Added object is not an editable element');
|
||||
}
|
||||
|
||||
const typeName = element.getEditableElementInfo().typeName;
|
||||
|
||||
dashboardEditActions.edit({
|
||||
description: t('dashboard.edit-actions.add', 'Add {{typeName}}', { typeName }),
|
||||
addedObject,
|
||||
source,
|
||||
perform,
|
||||
undo,
|
||||
});
|
||||
},
|
||||
|
||||
removeElement(props: RemoveElementActionHelperProps) {
|
||||
const { removedObject, source, perform, undo } = props;
|
||||
|
||||
const element = getEditableElementFor(removedObject);
|
||||
if (!element) {
|
||||
throw new Error('Removed object is not an editable element');
|
||||
}
|
||||
|
||||
const typeName = element.getEditableElementInfo().typeName;
|
||||
|
||||
dashboardEditActions.edit({
|
||||
description: t('dashboard.edit-actions.remove', 'Remove {{typeName}}', { typeName }),
|
||||
removedObject,
|
||||
source,
|
||||
perform,
|
||||
undo,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -150,7 +150,7 @@ setDashboardLoaderSrv({
|
||||
|
||||
describe('DashboardScenePage', () => {
|
||||
beforeEach(() => {
|
||||
locationService.push('/');
|
||||
locationService.push('/d/my-dash-uid');
|
||||
getDashboardScenePageStateManager().clearDashboardCache();
|
||||
loadDashboardMock.mockClear();
|
||||
loadDashboardMock.mockResolvedValue({ dashboard: simpleDashboard, meta: { slug: '123' } });
|
||||
@@ -339,6 +339,11 @@ describe('DashboardScenePage', () => {
|
||||
});
|
||||
|
||||
describe('errors rendering', () => {
|
||||
const origError = console.error;
|
||||
const consoleErrorMock = jest.fn();
|
||||
afterEach(() => (console.error = origError));
|
||||
beforeEach(() => (console.error = consoleErrorMock));
|
||||
|
||||
it('should render dashboard not found notice when dashboard... not found', async () => {
|
||||
setupLoadDashboardMockReject({
|
||||
status: 404,
|
||||
@@ -362,6 +367,7 @@ describe('DashboardScenePage', () => {
|
||||
|
||||
expect(await screen.findByTestId(selectors.components.EntityNotFound.container)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render error alert for backend errors', async () => {
|
||||
setupLoadDashboardMockReject({
|
||||
status: 500,
|
||||
@@ -386,6 +392,7 @@ describe('DashboardScenePage', () => {
|
||||
expect(await screen.findByTestId('dashboard-page-error')).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('dashboard-page-error')).toHaveTextContent('Internal server error');
|
||||
});
|
||||
|
||||
it('should render error alert for runtime errors', async () => {
|
||||
setupLoadDashboardRuntimeErrorMock();
|
||||
|
||||
@@ -398,8 +405,12 @@ describe('DashboardScenePage', () => {
|
||||
|
||||
describe('UnifiedDashboardScenePageStateManager', () => {
|
||||
it('should reset active manager when unmounting', async () => {
|
||||
// This test is missing setup for v2 api so it erroring
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const manager = getDashboardScenePageStateManager();
|
||||
manager.setActiveManager('v2');
|
||||
|
||||
const { unmount } = setup();
|
||||
|
||||
expect(manager['activeManager']).toBeInstanceOf(DashboardScenePageStateManagerV2);
|
||||
|
||||
@@ -293,6 +293,7 @@ abstract class DashboardScenePageStateManagerBase<T>
|
||||
const status = getStatusFromError(err);
|
||||
const message = getMessageFromError(err);
|
||||
const messageId = getMessageIdFromError(err);
|
||||
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
loadError: {
|
||||
@@ -301,6 +302,11 @@ abstract class DashboardScenePageStateManagerBase<T>
|
||||
messageId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isFetchError(err)) {
|
||||
console.error('Error loading dashboard:', err);
|
||||
}
|
||||
|
||||
// If the error is a DashboardVersionError, we want to throw it so that the error boundary is triggered
|
||||
// This enables us to switch to the correct version of the dashboard
|
||||
if (err instanceof DashboardVersionError) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/Opti
|
||||
import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard';
|
||||
import { saveLibPanel } from 'app/features/library-panels/state/api';
|
||||
|
||||
import { DashboardEditActionEvent } from '../edit-pane/shared';
|
||||
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
|
||||
import { getPanelChanges } from '../saving/getDashboardChanges';
|
||||
import { UNCONFIGURED_PANEL_PLUGIN_ID } from '../scene/UnconfiguredPanel';
|
||||
@@ -83,6 +84,13 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
|
||||
panel.changePluginType('timeseries');
|
||||
}
|
||||
|
||||
this._subs.add(
|
||||
this._layoutItem.subscribeToEvent(DashboardEditActionEvent, ({ payload }) => {
|
||||
// TODO add support for undo/redo within panel edit
|
||||
payload.perform();
|
||||
})
|
||||
);
|
||||
|
||||
const deactivateParents = activateSceneObjectAndParentTree(panel);
|
||||
|
||||
this.waitForPlugin();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
import { CoreApp } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t } from '@grafana/i18n/internal';
|
||||
@@ -10,6 +12,7 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv';
|
||||
|
||||
import { dashboardEditActions } from '../edit-pane/shared';
|
||||
import { VizPanelLinks } from '../scene/PanelLinks';
|
||||
import { PanelTimeRange } from '../scene/PanelTimeRange';
|
||||
import { useEditPaneInputAutoFocus } from '../scene/layouts-shared/utils';
|
||||
@@ -41,7 +44,7 @@ export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescri
|
||||
},
|
||||
addon: config.featureToggles.dashgpt && (
|
||||
<GenAIPanelTitleButton
|
||||
onGenerate={(title) => setPanelTitle(panel, title)}
|
||||
onGenerate={(title) => editPanelTitleAction(panel, title)}
|
||||
panel={vizPanelToPanel(panel)}
|
||||
dashboard={transformSceneToSaveModel(dashboard)}
|
||||
/>
|
||||
@@ -112,6 +115,7 @@ function ScenePanelLinksEditor({ panelLinks }: ScenePanelLinksEditorProps) {
|
||||
export function PanelFrameTitleInput({ panel, isNewElement }: { panel: VizPanel; isNewElement?: boolean }) {
|
||||
const { title } = panel.useState();
|
||||
const notInPanelEdit = panel.getPanelContext().app !== CoreApp.PanelEditor;
|
||||
const [prevTitle, setPrevTitle] = React.useState(panel.state.title);
|
||||
|
||||
let ref = useEditPaneInputAutoFocus({
|
||||
autoFocus: notInPanelEdit && isNewElement,
|
||||
@@ -122,41 +126,69 @@ export function PanelFrameTitleInput({ panel, isNewElement }: { panel: VizPanel;
|
||||
ref={ref}
|
||||
data-testid={selectors.components.PanelEditor.OptionsPane.fieldInput('Title')}
|
||||
value={title}
|
||||
onChange={(e) => setPanelTitle(panel, e.currentTarget.value)}
|
||||
onFocus={() => setPrevTitle(title)}
|
||||
onBlur={() => editPanelTitleAction(panel, title, prevTitle)}
|
||||
// The full action (that can be undone) is done by setPanelTitle,
|
||||
// But to see changes in the input field, canvas and outline we change the real value here
|
||||
onChange={(e) => updatePanelTitleState(panel, e.currentTarget.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelDescriptionTextArea({ panel }: { panel: VizPanel }) {
|
||||
const { description } = panel.useState();
|
||||
const [prevDescription, setPrevDescription] = React.useState(panel.state.description);
|
||||
|
||||
return (
|
||||
<TextArea
|
||||
id="description-text-area"
|
||||
value={description}
|
||||
onChange={(e) => panel.setState({ description: e.currentTarget.value })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelBackgroundSwitch({ panel }: { panel: VizPanel }) {
|
||||
const { displayMode } = panel.useState();
|
||||
|
||||
return (
|
||||
<Switch
|
||||
value={displayMode === 'transparent'}
|
||||
id="transparent-background"
|
||||
onChange={() => {
|
||||
panel.setState({
|
||||
displayMode: panel.state.displayMode === 'transparent' ? 'default' : 'transparent',
|
||||
onChange={(evt) => panel.setState({ description: evt.currentTarget.value })}
|
||||
onFocus={() => setPrevDescription(panel.state.description)}
|
||||
onBlur={() => {
|
||||
dashboardEditActions.edit({
|
||||
description: t('dashboard.edit-actions.panel-description', 'Change panel description'),
|
||||
source: panel,
|
||||
perform: () => panel.setState({ description: description }),
|
||||
undo: () => panel.setState({ description: prevDescription }),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function setPanelTitle(panel: VizPanel, title: string) {
|
||||
panel.setState({ title: title, hoverHeader: getUpdatedHoverHeader(title, panel.state.$timeRange) });
|
||||
export function PanelBackgroundSwitch({ panel }: { panel: VizPanel }) {
|
||||
const { displayMode = 'default' } = panel.useState();
|
||||
|
||||
const onChange = () => {
|
||||
const newDisplayMode = displayMode === 'default' ? 'transparent' : 'default';
|
||||
|
||||
dashboardEditActions.edit({
|
||||
description: t('dashboard.edit-actions.panel-background', 'Change panel background'),
|
||||
source: panel,
|
||||
perform: () => panel.setState({ displayMode: newDisplayMode }),
|
||||
undo: () => panel.setState({ displayMode: displayMode }),
|
||||
});
|
||||
};
|
||||
|
||||
return <Switch value={displayMode === 'transparent'} id="transparent-background" onChange={onChange} />;
|
||||
}
|
||||
|
||||
function updatePanelTitleState(panel: VizPanel, title: string) {
|
||||
panel.setState({ title, hoverHeader: getUpdatedHoverHeader(title, panel.state.$timeRange) });
|
||||
}
|
||||
|
||||
export function editPanelTitleAction(panel: VizPanel, title: string, prevTitle: string = panel.state.title) {
|
||||
if (title === prevTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
dashboardEditActions.edit({
|
||||
description: t('dashboard.edit-actions.panel-title', 'Change panel title'),
|
||||
source: panel,
|
||||
perform: () => updatePanelTitleState(panel, title),
|
||||
undo: () => updatePanelTitleState(panel, prevTitle),
|
||||
});
|
||||
}
|
||||
|
||||
export function getUpdatedHoverHeader(title: string, timeRange: SceneTimeRangeLike | undefined): boolean {
|
||||
|
||||
@@ -223,7 +223,7 @@ describe('DashboardScene', () => {
|
||||
it('A change to folderUid should set isDirty true', () => {
|
||||
const prevMeta = { ...scene.state.meta };
|
||||
|
||||
// The worker only detects changes in the model, so the folder change should be detected anyway
|
||||
// The worker detects changes in the model, so the folder change should be detected anyway
|
||||
mockResultsOfDetectChangesWorker({ hasChanges: false });
|
||||
|
||||
scene.setState({
|
||||
@@ -452,13 +452,6 @@ describe('DashboardScene', () => {
|
||||
expect(panel.state.key).toBe('panel-7');
|
||||
});
|
||||
|
||||
it('Should select new panel', () => {
|
||||
scene.state.editPane.activate();
|
||||
|
||||
const panel = scene.onCreateNewPanel();
|
||||
expect(scene.state.editPane.state.selection?.getFirstObject()).toBe(panel);
|
||||
});
|
||||
|
||||
it('Should select new row', () => {
|
||||
scene.state.editPane.activate();
|
||||
|
||||
@@ -466,13 +459,6 @@ describe('DashboardScene', () => {
|
||||
expect(scene.state.editPane.state.selection?.getFirstObject()).toBe(row);
|
||||
});
|
||||
|
||||
it('Should select new tab', () => {
|
||||
scene.state.editPane.activate();
|
||||
|
||||
const tab = scene.onCreateNewTab();
|
||||
expect(scene.state.editPane.state.selection?.getFirstObject()).toBe(tab);
|
||||
});
|
||||
|
||||
it('Should fail to copy a panel if it does not have a grid item parent', () => {
|
||||
const vizPanel = new VizPanel({
|
||||
title: 'Panel Title',
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
ResourceForCreate,
|
||||
} from '../../apiserver/types';
|
||||
import { DashboardEditPane } from '../edit-pane/DashboardEditPane';
|
||||
import { dashboardEditActions } from '../edit-pane/shared';
|
||||
import { PanelEditor } from '../panel-edit/PanelEditor';
|
||||
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
|
||||
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
|
||||
@@ -85,10 +86,10 @@ import { setupKeyboardShortcuts } from './keyboardShortcuts';
|
||||
import { AutoGridItem } from './layout-auto-grid/AutoGridItem';
|
||||
import { DashboardGridItem } from './layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
|
||||
import { addNewRowTo, addNewTabTo } from './layouts-shared/addNew';
|
||||
import { addNewRowTo } from './layouts-shared/addNew';
|
||||
import { clearClipboard } from './layouts-shared/paste';
|
||||
import { DashboardLayoutManager } from './types/DashboardLayoutManager';
|
||||
import { isLayoutParent, LayoutParent } from './types/LayoutParent';
|
||||
import { LayoutParent } from './types/LayoutParent';
|
||||
|
||||
export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'graphTooltip', 'links', 'meta', 'preload'];
|
||||
export const PANEL_SEARCH_VAR = 'systemPanelFilterVar';
|
||||
@@ -495,13 +496,6 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
this.onEnterEditMode();
|
||||
}
|
||||
|
||||
const selectedObject = this.state.editPane.getSelection();
|
||||
if (selectedObject && !Array.isArray(selectedObject) && isLayoutParent(selectedObject)) {
|
||||
const layout = selectedObject.getLayout();
|
||||
layout.addPanel(vizPanel);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add panel to layout
|
||||
this.state.body.addPanel(vizPanel);
|
||||
}
|
||||
@@ -633,25 +627,9 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
}
|
||||
|
||||
public onCreateNewRow() {
|
||||
const selectedObject = this.state.editPane.getSelection();
|
||||
if (selectedObject && !Array.isArray(selectedObject) && isLayoutParent(selectedObject)) {
|
||||
const layout = selectedObject.getLayout();
|
||||
return addNewRowTo(layout);
|
||||
}
|
||||
|
||||
return addNewRowTo(this.state.body);
|
||||
}
|
||||
|
||||
public onCreateNewTab() {
|
||||
const selectedObject = this.state.editPane.getSelection();
|
||||
if (selectedObject && !Array.isArray(selectedObject) && isLayoutParent(selectedObject)) {
|
||||
const layout = selectedObject.getLayout();
|
||||
return addNewTabTo(layout);
|
||||
}
|
||||
|
||||
return addNewTabTo(this.state.body);
|
||||
}
|
||||
|
||||
public onCreateNewPanel(): VizPanel {
|
||||
const vizPanel = getDefaultVizPanel();
|
||||
this.addPanel(vizPanel);
|
||||
@@ -659,8 +637,14 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
}
|
||||
|
||||
public switchLayout(layout: DashboardLayoutManager) {
|
||||
this.setState({ body: layout });
|
||||
this.state.body.activateRepeaters?.();
|
||||
const currentLayout = this.state.body;
|
||||
|
||||
dashboardEditActions.edit({
|
||||
description: t('dashboard.edit-actions.switch-layout', 'Switch layout'),
|
||||
source: this,
|
||||
perform: () => this.setState({ body: layout }),
|
||||
undo: () => this.setState({ body: currentLayout }),
|
||||
});
|
||||
}
|
||||
|
||||
public getLayout(): DashboardLayoutManager {
|
||||
|
||||
@@ -168,6 +168,27 @@ export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements
|
||||
};
|
||||
}
|
||||
|
||||
public editingStarted() {
|
||||
if (!this.state.variableName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((this.state.repeatedPanels?.length ?? 0) > 1) {
|
||||
this.state.body.setState({
|
||||
$variables: this.state.repeatedPanels![0].state.$variables?.clone(),
|
||||
$data: this.state.repeatedPanels![0].state.$data?.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public editingCompleted(withChanges: boolean) {
|
||||
if (withChanges) {
|
||||
this._prevRepeatValues = undefined;
|
||||
}
|
||||
|
||||
this.performRepeat();
|
||||
}
|
||||
|
||||
public scrollIntoView() {
|
||||
scrollCanvasElementIntoView(this, this.containerRef);
|
||||
}
|
||||
|
||||
+39
-8
@@ -4,7 +4,7 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa
|
||||
import { GRID_CELL_VMARGIN } from 'app/core/constants';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
import { NewObjectAddedToCanvasEvent, ObjectRemovedFromCanvasEvent } from '../../edit-pane/shared';
|
||||
import { dashboardEditActions, NewObjectAddedToCanvasEvent } from '../../edit-pane/shared';
|
||||
import { serializeAutoGridLayout } from '../../serialization/layoutSerializers/AutoGridLayoutSerializer';
|
||||
import { joinCloneKeys } from '../../utils/clone';
|
||||
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
|
||||
@@ -94,11 +94,20 @@ export class AutoGridLayoutManager
|
||||
vizPanel.setState({ key: getVizPanelKeyForPanelId(panelId) });
|
||||
vizPanel.clearParent();
|
||||
|
||||
this.state.layout.setState({
|
||||
children: [...this.state.layout.state.children, new AutoGridItem({ body: vizPanel })],
|
||||
});
|
||||
const newGridItem = new AutoGridItem({ body: vizPanel });
|
||||
|
||||
this.publishEvent(new NewObjectAddedToCanvasEvent(vizPanel), true);
|
||||
dashboardEditActions.addElement({
|
||||
addedObject: vizPanel,
|
||||
source: this,
|
||||
perform: () => {
|
||||
this.state.layout.setState({ children: [...this.state.layout.state.children, newGridItem] });
|
||||
},
|
||||
undo: () => {
|
||||
this.state.layout.setState({
|
||||
children: this.state.layout.state.children.filter((child) => child !== newGridItem),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public pastePanel() {
|
||||
@@ -109,9 +118,31 @@ export class AutoGridLayoutManager
|
||||
}
|
||||
|
||||
public removePanel(panel: VizPanel) {
|
||||
const element = panel.parent;
|
||||
this.state.layout.setState({ children: this.state.layout.state.children.filter((child) => child !== element) });
|
||||
this.publishEvent(new ObjectRemovedFromCanvasEvent(panel), true);
|
||||
const gridItem = panel.parent;
|
||||
if (!(gridItem instanceof AutoGridItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gridItemIndex = this.state.layout.state.children.indexOf(gridItem);
|
||||
|
||||
dashboardEditActions.removeElement({
|
||||
removedObject: panel,
|
||||
source: this,
|
||||
perform: () => {
|
||||
this.state.layout.setState({
|
||||
children: this.state.layout.state.children.filter((child) => child !== gridItem),
|
||||
});
|
||||
},
|
||||
undo: () => {
|
||||
this.state.layout.setState({
|
||||
children: [
|
||||
...this.state.layout.state.children.slice(0, gridItemIndex),
|
||||
gridItem,
|
||||
...this.state.layout.state.children.slice(gridItemIndex),
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public duplicate(): DashboardLayoutManager {
|
||||
|
||||
+38
-11
@@ -22,6 +22,7 @@ import { GRID_COLUMN_COUNT } from 'app/core/constants';
|
||||
import DashboardEmpty from 'app/features/dashboard/dashgrid/DashboardEmpty';
|
||||
|
||||
import {
|
||||
dashboardEditActions,
|
||||
NewObjectAddedToCanvasEvent,
|
||||
ObjectRemovedFromCanvasEvent,
|
||||
ObjectsReorderedOnCanvasEvent,
|
||||
@@ -122,7 +123,18 @@ export class DefaultGridLayoutManager
|
||||
key: getGridItemKeyForPanelId(panelId),
|
||||
});
|
||||
|
||||
this.state.grid.setState({ children: [...this.state.grid.state.children, newGridItem] });
|
||||
dashboardEditActions.addElement({
|
||||
addedObject: vizPanel,
|
||||
source: this,
|
||||
perform: () => {
|
||||
this.state.grid.setState({ children: [...this.state.grid.state.children, newGridItem] });
|
||||
},
|
||||
undo: () => {
|
||||
this.state.grid.setState({
|
||||
children: this.state.grid.state.children.filter((child) => child !== newGridItem),
|
||||
});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const newGridItem = new DashboardGridItem({
|
||||
height: NEW_PANEL_HEIGHT,
|
||||
@@ -135,15 +147,24 @@ export class DefaultGridLayoutManager
|
||||
|
||||
this.state.grid.setState({ children: [newGridItem, ...this.state.grid.state.children] });
|
||||
}
|
||||
|
||||
this.publishEvent(new NewObjectAddedToCanvasEvent(vizPanel), true);
|
||||
}
|
||||
|
||||
public pastePanel() {
|
||||
const emptySpace = findSpaceForNewPanel(this.state.grid);
|
||||
const panel = getDashboardGridItemFromClipboard(getDashboardSceneFor(this), emptySpace);
|
||||
this.state.grid.setState({ children: [...this.state.grid.state.children, panel] });
|
||||
this.publishEvent(new NewObjectAddedToCanvasEvent(panel), true);
|
||||
const newGridItem = getDashboardGridItemFromClipboard(getDashboardSceneFor(this), emptySpace);
|
||||
|
||||
dashboardEditActions.edit({
|
||||
description: t('dashboard.edit-actions.paste-panel', 'Paste panel'),
|
||||
addedObject: newGridItem.state.body,
|
||||
source: this,
|
||||
perform: () => {
|
||||
this.state.grid.setState({ children: [...this.state.grid.state.children, newGridItem] });
|
||||
},
|
||||
undo: () => {
|
||||
this.state.grid.setState({ children: this.state.grid.state.children.filter((child) => child !== newGridItem) });
|
||||
},
|
||||
});
|
||||
|
||||
clearClipboard();
|
||||
}
|
||||
|
||||
@@ -170,11 +191,18 @@ export class DefaultGridLayoutManager
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.grid.setState({
|
||||
children: layout.state.children.filter((child) => child !== gridItem),
|
||||
});
|
||||
if (!config.featureToggles.dashboardNewLayouts) {
|
||||
// No undo/redo support in legacy edit mode
|
||||
layout.setState({ children: layout.state.children.filter((child) => child !== gridItem) });
|
||||
return;
|
||||
}
|
||||
|
||||
this.publishEvent(new ObjectRemovedFromCanvasEvent(panel), true);
|
||||
dashboardEditActions.removeElement({
|
||||
removedObject: gridItem.state.body,
|
||||
source: this,
|
||||
perform: () => layout.setState({ children: layout.state.children.filter((child) => child !== gridItem) }),
|
||||
undo: () => layout.setState({ children: [...layout.state.children, gridItem] }),
|
||||
});
|
||||
}
|
||||
|
||||
public duplicatePanel(vizPanel: VizPanel) {
|
||||
@@ -226,7 +254,6 @@ export class DefaultGridLayoutManager
|
||||
}
|
||||
|
||||
grid.setState({ children: [...grid.state.children, newGridItem] });
|
||||
|
||||
this.publishEvent(new NewObjectAddedToCanvasEvent(newPanel), true);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
|
||||
|
||||
import {
|
||||
NewObjectAddedToCanvasEvent,
|
||||
dashboardEditActions,
|
||||
ObjectRemovedFromCanvasEvent,
|
||||
ObjectsReorderedOnCanvasEvent,
|
||||
} from '../../edit-pane/shared';
|
||||
@@ -104,8 +104,13 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
newRow.setState({ title: newTitle });
|
||||
}
|
||||
|
||||
this.setState({ rows: [...this.state.rows, newRow] });
|
||||
this.publishEvent(new NewObjectAddedToCanvasEvent(newRow), true);
|
||||
dashboardEditActions.addElement({
|
||||
addedObject: newRow,
|
||||
source: this,
|
||||
perform: () => this.setState({ rows: [...this.state.rows, newRow] }),
|
||||
undo: () => this.setState({ rows: this.state.rows.filter((r) => r !== newRow) }),
|
||||
});
|
||||
|
||||
return newRow;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { ToolbarButtonRow, useStyles2 } from '@grafana/ui';
|
||||
import { ToolbarButton, ToolbarButtonRow, useStyles2 } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
|
||||
|
||||
@@ -23,6 +23,7 @@ import { SaveDashboard } from './actions/SaveDashboard';
|
||||
import { SaveLibraryPanelButton } from './actions/SaveLibraryPanelButton';
|
||||
import { ShareDashboardButton } from './actions/ShareDashboardButton';
|
||||
import { UnlinkLibraryPanelButton } from './actions/UnlinkLibraryPanelButton';
|
||||
import { ToolbarActionProps } from './types';
|
||||
import { getDynamicActions, renderActionElements } from './utils';
|
||||
|
||||
export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => {
|
||||
@@ -102,6 +103,18 @@ export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => {
|
||||
group: 'panel',
|
||||
condition: showPanelButtons && isEditingLibraryPanel,
|
||||
},
|
||||
{
|
||||
key: 'dashboard-undo',
|
||||
component: UndoButton,
|
||||
group: 'dashboard',
|
||||
condition: isEditingAndShowingDashboard && dashboard.canEditDashboard(),
|
||||
},
|
||||
{
|
||||
key: 'dashboard-redo',
|
||||
component: RedoButton,
|
||||
group: 'dashboard',
|
||||
condition: isEditingAndShowingDashboard && dashboard.canEditDashboard(),
|
||||
},
|
||||
{
|
||||
key: 'dashboard-settings',
|
||||
component: DashboardSettingsButton,
|
||||
@@ -152,6 +165,36 @@ export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => {
|
||||
);
|
||||
};
|
||||
|
||||
function UndoButton({ dashboard }: ToolbarActionProps) {
|
||||
const editPane = dashboard.state.editPane;
|
||||
const { undoStack } = editPane.useState();
|
||||
const undoAction = undoStack[undoStack.length - 1];
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
icon="corner-up-left"
|
||||
disabled={undoStack.length === 0}
|
||||
onClick={() => editPane.undoAction()}
|
||||
tooltip={undoAction?.description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RedoButton({ dashboard }: ToolbarActionProps) {
|
||||
const editPane = dashboard.state.editPane;
|
||||
const { redoStack } = editPane.useState();
|
||||
const redoAction = redoStack[redoStack.length - 1];
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
icon="corner-up-right"
|
||||
disabled={redoStack.length === 0}
|
||||
tooltip={redoAction?.description}
|
||||
onClick={() => editPane.redoAction()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({ paddingLeft: theme.spacing(0.5) }),
|
||||
});
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, useTranslate } from '@grafana/i18n';
|
||||
import { Button, Dropdown, Menu } from '@grafana/ui';
|
||||
|
||||
import { DashboardInteractions } from '../../../utils/interactions';
|
||||
import { ToolbarActionProps } from '../types';
|
||||
|
||||
export function DashboardAddButton({ dashboard }: ToolbarActionProps) {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
icon="graph-bar"
|
||||
label={t('dashboard.toolbar.add-new.menu.panel', 'Panel')}
|
||||
testId={selectors.components.PageToolbar.itemButton('add_visualization')}
|
||||
onClick={() => dashboard.onCreateNewPanel()}
|
||||
/>
|
||||
<Menu.Item
|
||||
icon="import"
|
||||
label={t('dashboard.toolbar.add-new.menu.lib-panel', 'Library panel')}
|
||||
testId={selectors.pages.AddDashboard.itemButton('Add new panel from panel library menu item')}
|
||||
onClick={() => {
|
||||
dashboard.onShowAddLibraryPanelDrawer();
|
||||
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' });
|
||||
}}
|
||||
/>
|
||||
<Menu.Item
|
||||
icon="list-ul"
|
||||
label={t('dashboard.toolbar.add-new.menu.row', 'Row')}
|
||||
testId={selectors.components.PageToolbar.itemButton('add_row')}
|
||||
onClick={() => dashboard.onCreateNewRow()}
|
||||
/>
|
||||
<Menu.Item
|
||||
icon="layer-group"
|
||||
label={t('dashboard.toolbar.add-new.menu.tab', 'Tab')}
|
||||
testId={selectors.components.PageToolbar.itemButton('add_tab')}
|
||||
onClick={() => dashboard.onCreateNewTab()}
|
||||
/>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
tooltip={t('dashboard.toolbar.add-new.button.tooltip', 'Add panels and other elements')}
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
fill="outline"
|
||||
data-testid={selectors.components.PageToolbar.itemButton('Add button')}
|
||||
>
|
||||
<Trans i18nKey="dashboard.toolbar.add-new.button.label">Add</Trans>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -4058,6 +4058,15 @@
|
||||
"tooltip-remove-property": "Remove property"
|
||||
}
|
||||
},
|
||||
"edit-actions": {
|
||||
"add": "Add {{typeName}}",
|
||||
"panel-background": "Change panel background",
|
||||
"panel-description": "Change panel description",
|
||||
"panel-title": "Change panel title",
|
||||
"paste-panel": "Paste panel",
|
||||
"remove": "Remove {{typeName}}",
|
||||
"switch-layout": "Switch layout"
|
||||
},
|
||||
"edit-pane": {
|
||||
"elements": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -4610,18 +4619,6 @@
|
||||
},
|
||||
"toolbar": {
|
||||
"add": "Add",
|
||||
"add-new": {
|
||||
"button": {
|
||||
"label": "Add",
|
||||
"tooltip": "Add panels and other elements"
|
||||
},
|
||||
"menu": {
|
||||
"lib-panel": "Library panel",
|
||||
"panel": "Panel",
|
||||
"row": "Row",
|
||||
"tab": "Tab"
|
||||
}
|
||||
},
|
||||
"alert-rules": "Alert rules",
|
||||
"back-to-dashboard": "Back to dashboard",
|
||||
"dashboard-settings": {
|
||||
|
||||
Reference in New Issue
Block a user