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:
Torkel Ödegaard
2025-06-03 14:13:17 +02:00
committed by GitHub
parent 3cad6cf880
commit 3c6a9da3cb
22 changed files with 696 additions and 370 deletions
+1 -1
View File
@@ -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": [
+2
View File
@@ -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,
+2
View File
@@ -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);
}
@@ -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 {
@@ -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>
);
}
+9 -12
View File
@@ -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": {