diff --git a/.betterer.results b/.betterer.results index 02d1f01333c..a044a49d3c5 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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": [ diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index a5dc7794f1c..79df07aae5a 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -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, diff --git a/public/app/core/icons/cached.json b/public/app/core/icons/cached.json index fa49afb6bf4..520a7c14941 100644 --- a/public/app/core/icons/cached.json +++ b/public/app/core/icons/cached.json @@ -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", diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.test.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.test.tsx new file mode 100644 index 00000000000..c6d535e3c9e --- /dev/null +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.test.tsx @@ -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; +} diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index 73a4d70a308..d7642567792 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -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 { @@ -54,6 +36,8 @@ export class DashboardEditPane extends SceneObjectBase { 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 { 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 { ); } + /** + * 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 { 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 ( - <> -
- -
- - {openOverlay && ( - - - - )} - - ); - } - - 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 ( -
-
-
- -
-
-
- - {!outlineCollapsed && ( -
- - - -
- )} -
-
-
- ); -} - -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', - }), - }; -} diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx new file mode 100644 index 00000000000..55536d98ccc --- /dev/null +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx @@ -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 ( + <> +
+ +
+ + {openOverlay && ( + + + + )} + + ); + } + + 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 ( +
+
+
+ +
+
+
+ + {!outlineCollapsed && ( +
+ + + +
+ )} +
+
+
+ ); +} + +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', + }), + }; +} diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx index 50b1509a6e2..76a2effcb6d 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx @@ -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 { diff --git a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx index d633c8a6495..5d7e1485cbb 100644 --- a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx @@ -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[]) { diff --git a/public/app/features/dashboard-scene/edit-pane/shared.ts b/public/app/features/dashboard-scene/edit-pane/shared.ts index dac06f09cdd..1caf0a10e51 100644 --- a/public/app/features/dashboard-scene/edit-pane/shared.ts +++ b/public/app/features/dashboard-scene/edit-pane/shared.ts @@ -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 { 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 { + 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, + }); + }, +}; diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx index df5c82abbc9..97c3b1926d9 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx @@ -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); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 43417eceb77..196809da8e6 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -293,6 +293,7 @@ abstract class DashboardScenePageStateManagerBase const status = getStatusFromError(err); const message = getMessageFromError(err); const messageId = getMessageIdFromError(err); + this.setState({ isLoading: false, loadError: { @@ -301,6 +302,11 @@ abstract class DashboardScenePageStateManagerBase 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) { diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index 01929d48451..ae823d11107 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -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 { 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(); diff --git a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx index 503406a0118..6821a05e542 100644 --- a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx @@ -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 && ( 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 (