From 61c7fcc270075e440d9c60dba0c070630369675e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 5 Feb 2024 16:08:12 +0100 Subject: [PATCH] DashboardScene: Action toolbar progress (#81664) * DashboardScene: Action toolbar progress * Add discard confirmation modal * minor fix * Update * tweaked * Updating * Progress * Update * Update * Added some unit tests * fix test * Change name to Exit edit * Tweaks * fix test * Minor margin fix * Move share to left of edit --- .../ToolbarButton/ToolbarButtonRow.tsx | 4 +- .../AppChrome/NavToolbar/NavToolbar.tsx | 2 +- .../alerting/unified/initAlerting.tsx | 2 +- .../scene/DashboardControls.tsx | 9 +- .../scene/DashboardScene.test.tsx | 10 +- .../dashboard-scene/scene/DashboardScene.tsx | 24 +- .../scene/DashboardSceneRenderer.tsx | 5 +- .../scene/NavToolbarActions.test.tsx | 83 ++++ .../scene/NavToolbarActions.tsx | 362 ++++++++++++------ .../dashboard-scene/utils/interactions.ts | 4 +- 10 files changed, 370 insertions(+), 135 deletions(-) create mode 100644 public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx 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 = ( + + { + DashboardInteractions.toolbarSaveClick(); + dashboard.openSaveDrawer({}); + }} + /> + { + DashboardInteractions.toolbarSaveAsClick(); + dashboard.openSaveDrawer({ saveAsCopy: true }); + }} + /> + + ); + + return ( + - ); - } - toolbarActions.push( - + +