diff --git a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButtonRow.tsx b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButtonRow.tsx
index c6cecb958b5..c3f59a9d9f9 100644
--- a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButtonRow.tsx
+++ b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButtonRow.tsx
@@ -117,7 +117,7 @@ const getStyles = (theme: GrafanaTheme2, overflowButtonOrder: number, alignment:
alignItems: 'center',
backgroundColor: theme.colors.background.primary,
borderRadius: theme.shape.radius.default,
- boxShadow: theme.shadows.z3,
+ boxShadow: theme.shadows.z2,
display: 'flex',
flexWrap: 'wrap',
gap: theme.spacing(1),
@@ -128,7 +128,7 @@ const getStyles = (theme: GrafanaTheme2, overflowButtonOrder: number, alignment:
right: 0,
top: '100%',
width: 'max-content',
- zIndex: theme.zIndex.sidemenu,
+ zIndex: theme.zIndex.dropdown,
}),
container: css({
alignItems: 'center',
diff --git a/public/app/core/components/AppChrome/NavToolbar/NavToolbar.tsx b/public/app/core/components/AppChrome/NavToolbar/NavToolbar.tsx
index 3c7ce4bf2bb..d9e41335b3f 100644
--- a/public/app/core/components/AppChrome/NavToolbar/NavToolbar.tsx
+++ b/public/app/core/components/AppChrome/NavToolbar/NavToolbar.tsx
@@ -62,7 +62,6 @@ export function NavToolbar({
{actions}
- {actions &&
}
{searchBarHidden && (
)}
+ {actions &&
}
config.unifiedAlertingEnabled,
component: ({ dashboard }) => (
-
+
{dashboard && }
),
diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.tsx
index 509e1bf65c7..3a57af32991 100644
--- a/public/app/features/dashboard-scene/scene/DashboardControls.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardControls.tsx
@@ -1,9 +1,7 @@
import React from 'react';
import { SceneObjectState, SceneObject, SceneObjectBase, SceneComponentProps } from '@grafana/scenes';
-import { Box, Stack, ToolbarButton } from '@grafana/ui';
-
-import { getDashboardSceneFor } from '../utils/utils';
+import { Box, Stack } from '@grafana/ui';
import { DashboardLinksControls } from './DashboardLinksControls';
@@ -18,9 +16,7 @@ export class DashboardControls extends SceneObjectBase {
}
function DashboardControlsRenderer({ model }: SceneComponentProps) {
- const dashboard = getDashboardSceneFor(model);
const { variableControls, linkControls, timeControls, hideTimeControls } = model.useState();
- const { isEditing } = dashboard.useState();
return (
- {isEditing && (
-
- )}
{!hideTimeControls && timeControls.map((c) => )}
diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx
index 6d80ff885c3..85c0986fcf9 100644
--- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx
@@ -57,7 +57,7 @@ describe('DashboardScene', () => {
expect(scene.state.isDirty).toBe(true);
- scene.onDiscard();
+ scene.exitEditMode({ skipConfirm: true });
const gridItem2 = sceneGraph.findObject(scene, (p) => p.state.key === 'griditem-1') as SceneGridItem;
expect(gridItem2.state.x).toBe(0);
});
@@ -77,7 +77,7 @@ describe('DashboardScene', () => {
expect(scene.state.isDirty).toBe(true);
- scene.onDiscard();
+ scene.exitEditMode({ skipConfirm: true });
expect(scene.state[prop]).toEqual(prevState);
}
);
@@ -89,7 +89,7 @@ describe('DashboardScene', () => {
expect(scene.state.isDirty).toBe(true);
- scene.onDiscard();
+ scene.exitEditMode({ skipConfirm: true });
expect(dashboardSceneGraph.getRefreshPicker(scene)!.state.intervals).toEqual(prevState);
});
@@ -100,7 +100,7 @@ describe('DashboardScene', () => {
expect(scene.state.isDirty).toBe(true);
- scene.onDiscard();
+ scene.exitEditMode({ skipConfirm: true });
expect(dashboardSceneGraph.getDashboardControls(scene)!.state.hideTimeControls).toEqual(prevState);
});
@@ -111,7 +111,7 @@ describe('DashboardScene', () => {
expect(scene.state.isDirty).toBe(true);
- scene.onDiscard();
+ scene.exitEditMode({ skipConfirm: true });
expect(sceneGraph.getTimeRange(scene)!.state.timeZone).toBe(prevState);
});
});
diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx
index 3a28e56244d..96f9acba477 100644
--- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx
@@ -26,6 +26,7 @@ import { DashboardModel } from 'app/features/dashboard/state';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { VariablesChanged } from 'app/features/variables/types';
import { DashboardDTO, DashboardMeta, SaveDashboardResponseDTO } from 'app/types';
+import { ShowConfirmModalEvent } from 'app/types/events';
import { PanelEditor } from '../panel-edit/PanelEditor';
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
@@ -213,12 +214,29 @@ export class DashboardScene extends SceneObjectBase {
}
}
- public onDiscard = () => {
+ public exitEditMode({ skipConfirm }: { skipConfirm: boolean }) {
if (!this.canDiscard()) {
console.error('Trying to discard back to a state that does not exist, initialState undefined');
return;
}
+ if (!this.state.isDirty || skipConfirm) {
+ this.exitEditModeConfirmed();
+ return;
+ }
+
+ appEvents.publish(
+ new ShowConfirmModalEvent({
+ title: 'Discard changes to dashboard?',
+ text: `You have unsaved changes to this dashboard. Are you sure you want to discard them?`,
+ icon: 'trash-alt',
+ yesText: 'Discard',
+ onConfirm: this.exitEditModeConfirmed.bind(this),
+ })
+ );
+ }
+
+ private exitEditModeConfirmed() {
// No need to listen to changes anymore
this.stopTrackingChanges();
// Stop url sync before updating url
@@ -244,7 +262,7 @@ export class DashboardScene extends SceneObjectBase {
this.startUrlSync();
// Disable grid dragging
this.propagateEditModeChange();
- };
+ }
public canDiscard() {
return this._initialState !== undefined;
@@ -266,7 +284,7 @@ export class DashboardScene extends SceneObjectBase {
newState.version = versionRsp.version;
this._initialState = newState;
- this.onDiscard();
+ this.exitEditMode({ skipConfirm: false });
return true;
};
diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx
index 7dbe7a8eda9..919e1502dde 100644
--- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx
@@ -20,6 +20,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps (
))}
-
+ {showDebugger && }
)}
@@ -83,7 +84,7 @@ function getStyles(theme: GrafanaTheme2) {
position: 'sticky',
top: 0,
background: theme.colors.background.canvas,
- zIndex: theme.zIndex.navbarFixed,
+ zIndex: theme.zIndex.activePanel,
padding: theme.spacing(2, 0),
}),
};
diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx
new file mode 100644
index 00000000000..993c1e97a7b
--- /dev/null
+++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx
@@ -0,0 +1,83 @@
+import { screen, render } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { TestProvider } from 'test/helpers/TestProvider';
+import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
+
+import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
+import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
+
+import { ToolbarActions } from './NavToolbarActions';
+
+describe('NavToolbarActions', () => {
+ describe('Give an already saved dashboard', () => {
+ it('Should show correct buttons when not in editing', async () => {
+ setup();
+
+ expect(screen.queryByText('Save dashboard')).not.toBeInTheDocument();
+ expect(await screen.findByText('Edit')).toBeInTheDocument();
+ expect(await screen.findByText('Share')).toBeInTheDocument();
+ });
+
+ it('Should show correct buttons when editing', async () => {
+ setup();
+
+ await userEvent.click(await screen.findByText('Edit'));
+
+ expect(await screen.findByText('Save dashboard')).toBeInTheDocument();
+ expect(await screen.findByText('Exit edit')).toBeInTheDocument();
+ expect(screen.queryByText('Edit')).not.toBeInTheDocument();
+ expect(screen.queryByText('Share')).not.toBeInTheDocument();
+ });
+
+ it('Should show correct buttons when in settings menu', async () => {
+ setup();
+
+ await userEvent.click(await screen.findByText('Edit'));
+ await userEvent.click(await screen.findByText('Settings'));
+
+ expect(await screen.findByText('Save dashboard')).toBeInTheDocument();
+ expect(await screen.findByText('Back to dashboard')).toBeInTheDocument();
+ });
+ });
+});
+
+let cleanUp = () => {};
+
+function setup() {
+ const dashboard = transformSaveModelToScene({
+ dashboard: {
+ title: 'hello',
+ uid: 'my-uid',
+ schemaVersion: 30,
+ panels: [],
+ version: 10,
+ },
+ meta: {
+ canSave: true,
+ },
+ });
+
+ // Clear any data layers
+ dashboard.setState({ $data: undefined });
+
+ const initialSaveModel = transformSceneToSaveModel(dashboard);
+ dashboard.setInitialSaveModel(initialSaveModel);
+
+ dashboard.startUrlSync();
+
+ cleanUp();
+ cleanUp = dashboard.activate();
+
+ const context = getGrafanaContextMock();
+
+ render(
+
+
+
+ );
+
+ const actions = context.chrome.state.getValue().actions;
+
+ return { dashboard, actions };
+}
diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
index 0f3bbfdfb0c..31f61c7ad82 100644
--- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
+++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
@@ -1,11 +1,13 @@
+import { css } from '@emotion/css';
import React from 'react';
+import { GrafanaTheme2 } from '@grafana/data';
import { locationService } from '@grafana/runtime';
-import { Button } from '@grafana/ui';
+import { Button, ButtonGroup, Dropdown, Icon, Menu, ToolbarButton, ToolbarButtonRow, useStyles2 } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator';
+import { contextSrv } from 'app/core/core';
import { t } from 'app/core/internationalization';
-import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { ShareModal } from '../sharing/ShareModal';
@@ -19,164 +21,300 @@ interface Props {
}
export const NavToolbarActions = React.memo
(({ dashboard }) => {
- const { actions = [], isEditing, viewPanelScene, isDirty, uid, meta, editview } = dashboard.useState();
- const toolbarActions = (actions ?? []).map((action) => );
- const rightToolbarActions: JSX.Element[] = [];
- const _legacyDashboardModel = getDashboardSrv().getCurrent();
+ const actions = (
+
+
+
+ );
- if (uid && !editview) {
- if (meta.canStar) {
+ return ;
+});
+
+NavToolbarActions.displayName = 'NavToolbarActions';
+
+/**
+ * This part is split into a separate componet to help test this
+ */
+export function ToolbarActions({ dashboard }: Props) {
+ const { isEditing, viewPanelScene, isDirty, uid, meta, editview } = dashboard.useState();
+ const canSaveAs = contextSrv.hasEditPermissionInFolders;
+ const toolbarActions: ToolbarAction[] = [];
+ const buttonWithExtraMargin = useStyles2(getStyles);
+
+ toolbarActions.push({
+ group: 'icon-actions',
+ condition: uid && !editview && Boolean(meta.canStar),
+ render: () => {
let desc = meta.isStarred
? t('dashboard.toolbar.unmark-favorite', 'Unmark as favorite')
: t('dashboard.toolbar.mark-favorite', 'Mark as favorite');
-
- toolbarActions.push(
-
+ }
+ key="star-dashboard-button"
onClick={() => {
DashboardInteractions.toolbarFavoritesClick();
dashboard.onStarDashboard();
}}
/>
);
- }
- toolbarActions.push(
- {
- DashboardInteractions.toolbarShareClick();
- dashboard.showModal(new ShareModal({ dashboardRef: dashboard.getRef() }));
- }}
- />
- );
+ },
+ });
- toolbarActions.push(
- (
+ locationService.push(`/d/${uid}`)}
/>
- );
- if (dynamicDashNavActions.left.length > 0) {
- dynamicDashNavActions.left.map((action, index) => {
+ ),
+ });
+
+ if (dynamicDashNavActions.left.length > 0) {
+ dynamicDashNavActions.left.map((action, index) => {
+ const props = { dashboard: getDashboardSrv().getCurrent()! };
+ if (action.show(props)) {
const Component = action.component;
- const element = ;
- typeof action.index === 'number'
- ? toolbarActions.splice(action.index, 0, element)
- : toolbarActions.push(element);
- });
- }
- }
-
- toolbarActions.push();
-
- if (dynamicDashNavActions.right.length > 0) {
- dynamicDashNavActions.right.map((action, index) => {
- const Component = action.component;
- const element = ;
- typeof action.index === 'number'
- ? rightToolbarActions.splice(action.index, 0, element)
- : rightToolbarActions.push(element);
+ toolbarActions.push({
+ group: 'icon-actions',
+ condition: true,
+ render: () => ,
+ });
+ }
});
-
- toolbarActions.push(...rightToolbarActions);
}
- if (viewPanelScene) {
- toolbarActions.push(
+ toolbarActions.push({
+ group: 'back-button',
+ condition: Boolean(viewPanelScene),
+ render: () => (
- );
+ ),
+ });
- return ;
- }
+ toolbarActions.push({
+ group: 'back-button',
+ condition: Boolean(editview),
+ render: () => (
+
+ ),
+ });
- if (!isEditing) {
- if (dashboard.canEditDashboard()) {
- toolbarActions.push(
-
- );
- }
- } else {
- if (dashboard.canEditDashboard()) {
- if (!dashboard.state.meta.isNew) {
- toolbarActions.push(
+ toolbarActions.push({
+ group: 'main-buttons',
+ condition: uid && !isEditing,
+ render: () => (
+
+ ),
+ });
+
+ toolbarActions.push({
+ group: 'main-buttons',
+ condition: !isEditing && dashboard.canEditDashboard() && !viewPanelScene,
+ render: () => (
+
+ ),
+ });
+
+ toolbarActions.push({
+ group: 'settings',
+ condition: isEditing && dashboard.canEditDashboard() && !viewPanelScene && !editview,
+ render: () => (
+
+ ),
+ });
+
+ toolbarActions.push({
+ group: 'main-buttons',
+ condition: isEditing && !editview && !meta.isNew,
+ render: () => (
+
+ ),
+ });
+
+ toolbarActions.push({
+ group: 'main-buttons',
+ condition: isEditing && (meta.canSave || canSaveAs),
+ render: () => {
+ // if we only can save
+ if (meta.isNew) {
+ return (
+ );
+ }
+
+ // If we only can save as copy
+ if (canSaveAs && !meta.canSave) {
+ return (
+
);
}
- if (dashboard.canDiscard()) {
- toolbarActions.push(
+
+ // If we can do both save and save as copy we show a button group with dropdown menu
+ const menu = (
+
+ );
+
+ return (
+
- );
- }
- toolbarActions.push(
-
+
+
+
+
);
+ },
+ });
+
+ const actionElements: React.ReactNode[] = [];
+ let lastGroup = '';
+
+ for (const action of toolbarActions) {
+ if (!action.condition) {
+ continue;
}
+
+ if (lastGroup && lastGroup !== action.group) {
+ lastGroup && actionElements.push();
+ }
+
+ actionElements.push(action.render());
+ lastGroup = action.group;
}
- return ;
-});
+ return actionElements;
+}
-NavToolbarActions.displayName = 'NavToolbarActions';
+interface ToolbarAction {
+ group: string;
+ condition?: boolean | string;
+ render: () => React.ReactNode;
+}
+
+function getStyles(theme: GrafanaTheme2) {
+ return css({ margin: theme.spacing(0, 0.5) });
+}
diff --git a/public/app/features/dashboard-scene/utils/interactions.ts b/public/app/features/dashboard-scene/utils/interactions.ts
index d96ecd166e2..a4da3b1b0be 100644
--- a/public/app/features/dashboard-scene/utils/interactions.ts
+++ b/public/app/features/dashboard-scene/utils/interactions.ts
@@ -135,7 +135,9 @@ export const DashboardInteractions = {
toolbarSaveClick: () => {
reportDashboardInteraction('toolbar_actions_clicked', { item: 'save' });
},
-
+ toolbarSaveAsClick: () => {
+ reportDashboardInteraction('toolbar_actions_clicked', { item: 'save_as' });
+ },
toolbarAddClick: () => {
reportDashboardInteraction('toolbar_actions_clicked', { item: 'add' });
},