diff --git a/.betterer.results b/.betterer.results index ca5c0a6a24e..94498648a29 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2524,6 +2524,9 @@ exports[`better eslint`] = { "public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "public/app/features/dashboard-scene/saving/DashboardPrompt.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/dashboard-scene/saving/DetectChangesWorker.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index cd324c10fe1..e1f90a24ea3 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -8,6 +8,8 @@ import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from 'app/features/dashboard/containers/types'; import { DashboardRoutes } from 'app/types'; +import { DashboardPrompt } from '../saving/DashboardPrompt'; + import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager'; export interface Props extends GrafanaRouteComponentProps {} @@ -51,7 +53,12 @@ export function DashboardScenePage({ match, route, queryParams, history }: Props ); } - return ; + return ( + <> + + + + ); } export default DashboardScenePage; diff --git a/public/app/features/dashboard-scene/saving/DashboardPrompt.test.tsx b/public/app/features/dashboard-scene/saving/DashboardPrompt.test.tsx new file mode 100644 index 00000000000..81b77a80f4b --- /dev/null +++ b/public/app/features/dashboard-scene/saving/DashboardPrompt.test.tsx @@ -0,0 +1,156 @@ +import { SceneGridItem, SceneGridLayout, SceneQueryRunner, SceneTimeRange, VizPanel, behaviors } from '@grafana/scenes'; +import { ContextSrv, setContextSrv } from 'app/core/services/context_srv'; + +import { DashboardControls } from '../scene/DashboardControls'; +import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; +import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; + +import { ignoreChanges } from './DashboardPrompt'; + +function getTestContext() { + const contextSrv = { isSignedIn: true, isEditor: true } as ContextSrv; + setContextSrv(contextSrv); + + return { contextSrv }; +} + +describe('DashboardPrompt', () => { + describe('ignoreChanges', () => { + beforeEach(() => { + getTestContext(); + }); + + describe('when called without original dashboard', () => { + it('then it should return true', () => { + const scene = buildTestScene(); + expect(ignoreChanges(scene, undefined)).toBe(true); + }); + }); + + describe('when called without current dashboard', () => { + it('then it should return true', () => { + const scene = buildTestScene(); + expect(ignoreChanges(null, scene.getInitialSaveModel())).toBe(true); + }); + }); + + describe('when called for a viewer without save permissions', () => { + it('then it should return true', () => { + const { contextSrv } = getTestContext(); + const scene = buildTestScene({ + meta: { + canSave: false, + }, + }); + contextSrv.isEditor = false; + + expect(ignoreChanges(scene, scene.getInitialSaveModel())).toBe(true); + }); + }); + + describe('when called for a viewer with save permissions', () => { + it('then it should return undefined', () => { + const { contextSrv } = getTestContext(); + + const scene = buildTestScene({ + meta: { + canSave: true, + }, + }); + const initialSaveModel = transformSceneToSaveModel(scene); + + contextSrv.isEditor = false; + + expect(ignoreChanges(scene, initialSaveModel)).toBe(undefined); + }); + }); + + describe('when called for an user that is not signed in', () => { + it('then it should return true', () => { + const { contextSrv } = getTestContext(); + const scene = buildTestScene({ + meta: { + canSave: true, + }, + }); + const initialSaveModel = transformSceneToSaveModel(scene); + + contextSrv.isSignedIn = false; + expect(ignoreChanges(scene, initialSaveModel)).toBe(true); + }); + }); + + describe('when called with fromScript', () => { + it('then it should return true', () => { + const scene = buildTestScene({ + meta: { + canSave: true, + fromScript: true, + }, + }); + const initialSaveModel = transformSceneToSaveModel(scene); + expect(ignoreChanges(scene, initialSaveModel)).toBe(true); + }); + }); + + describe('when called with fromFile', () => { + it('then it should return true', () => { + const scene = buildTestScene({ + meta: { + canSave: true, + fromScript: undefined, + fromFile: true, + }, + }); + const initialSaveModel = transformSceneToSaveModel(scene); + expect(ignoreChanges(scene, initialSaveModel)).toBe(true); + }); + }); + + describe('when called with canSave but without fromScript and fromFile', () => { + it('then it should return false', () => { + const scene = buildTestScene({ + meta: { + canSave: true, + fromScript: undefined, + fromFile: undefined, + }, + }); + const initialSaveModel = transformSceneToSaveModel(scene); + expect(ignoreChanges(scene, initialSaveModel)).toBe(undefined); + }); + }); + }); +}); + +function buildTestScene(overrides?: Partial) { + const scene = new DashboardScene({ + title: 'hello', + uid: 'dash-1', + description: 'hello description', + tags: ['tag1', 'tag2'], + editable: true, + $timeRange: new SceneTimeRange({ + timeZone: 'browser', + }), + controls: new DashboardControls({}), + $behaviors: [new behaviors.CursorSync({})], + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + body: new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), + }), + }), + ], + }), + ...overrides, + }); + + return scene; +} diff --git a/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx b/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx new file mode 100644 index 00000000000..dd8a9702f35 --- /dev/null +++ b/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx @@ -0,0 +1,188 @@ +import { css } from '@emotion/css'; +import * as H from 'history'; +import React, { useState, useContext, useEffect } from 'react'; +import { Prompt } from 'react-router'; + +import { locationService } from '@grafana/runtime'; +import { Dashboard } from '@grafana/schema/dist/esm/index.gen'; +import { ModalsContext, Modal, Button } from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; + +import { DashboardScene } from '../scene/DashboardScene'; + +interface DashboardPromptProps { + dashboard: DashboardScene; +} + +interface DashboardPromptState { + originalPath?: string; +} +export const DashboardPrompt = React.memo(({ dashboard }: DashboardPromptProps) => { + const [state, setState] = useState({ originalPath: undefined }); + const { originalPath } = state; + const { showModal, hideModal } = useContext(ModalsContext); + + useEffect(() => { + // This timeout delay is to wait for panels to load and migrate scheme before capturing the original state + // This is to minimize unsaved changes warnings due to automatic schema migrations + const timeoutId = setTimeout(() => { + const originalPath = locationService.getLocation().pathname; + setState({ originalPath }); + }, 1000); + + return () => { + clearTimeout(timeoutId); + }; + }, [dashboard, originalPath]); + + useEffect(() => { + const handleUnload = (event: BeforeUnloadEvent) => { + if (ignoreChanges(dashboard, dashboard.getInitialSaveModel())) { + return; + } + if (dashboard.state.isDirty) { + event.preventDefault(); + // No browser actually displays this message anymore. + // But Chrome requires it to be defined else the popup won't show. + event.returnValue = ''; + } + }; + window.addEventListener('beforeunload', handleUnload); + return () => window.removeEventListener('beforeunload', handleUnload); + }, [dashboard]); + + const onHistoryBlock = (location: H.Location) => { + // const panelInEdit = dashboard.state.editPanel; + // const search = new URLSearchParams(location.search); + + // TODO: Are we leaving panel edit & library panel? + + // if (panelInEdit && panelInEdit.libraryPanel && panelInEdit.hasChanged && !search.has('editPanel')) { + // showModal(SaveLibraryPanelModal, { + // isUnsavedPrompt: true, + // panel: dashboard.panelInEdit as PanelModelWithLibraryPanel, + // folderUid: dashboard.meta.folderUid ?? '', + // onConfirm: () => { + // hideModal(); + // moveToBlockedLocationAfterReactStateUpdate(location); + // }, + // onDiscard: () => { + // dispatch(discardPanelChanges()); + // moveToBlockedLocationAfterReactStateUpdate(location); + // hideModal(); + // }, + // onDismiss: hideModal, + // }); + // return false; + // } + + // Are we still on the same dashboard? + if (originalPath === location.pathname) { + return true; + } + + if (ignoreChanges(dashboard, dashboard.getInitialSaveModel())) { + return true; + } + + if (!dashboard.state.isDirty) { + return true; + } + + showModal(UnsavedChangesModal, { + dashboard, + onSaveDashboardClick: () => { + hideModal(); + dashboard.openSaveDrawer({ + onSaveSuccess: () => { + moveToBlockedLocationAfterReactStateUpdate(location); + }, + }); + }, + + onDiscard: () => { + dashboard.exitEditMode({ skipConfirm: true }); + hideModal(); + moveToBlockedLocationAfterReactStateUpdate(location); + }, + onDismiss: hideModal, + }); + + return false; + }; + + return ; +}); + +DashboardPrompt.displayName = 'DashboardPrompt'; + +function moveToBlockedLocationAfterReactStateUpdate(location?: H.Location | null) { + if (location) { + setTimeout(() => locationService.push(location), 10); + } +} +interface UnsavedChangesModalProps { + onDiscard: () => void; + onDismiss: () => void; + onSaveDashboardClick?: () => void; +} + +export const UnsavedChangesModal = ({ onDiscard, onDismiss, onSaveDashboardClick }: UnsavedChangesModalProps) => { + const styles = getStyles(); + return ( + +
Do you want to save your changes?
+ + + + + +
+ ); +}; + +const getStyles = () => ({ + modal: css({ + width: '500px', + }), +}); + +/** + * For some dashboards and users changes should be ignored * + */ +export function ignoreChanges(current: DashboardScene | null, original?: Dashboard) { + if (!original) { + return true; + } + + // Ignore changes if original is unsaved + if ((original as Dashboard).version === 0) { + return true; + } + + // Ignore changes if the user has been signed out + if (!contextSrv.isSignedIn) { + return true; + } + + if (!current) { + return true; + } + + const { canSave, fromScript, fromFile } = current.state.meta; + if (!contextSrv.isEditor && !canSave) { + return true; + } + + return !canSave || fromScript || fromFile; +} diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx index 8c4a83d5ea4..e52aa49698e 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx @@ -17,6 +17,7 @@ interface SaveDashboardDrawerState extends SceneObjectState { saveTimeRange?: boolean; saveVariables?: boolean; saveAsCopy?: boolean; + onSaveSuccess?: () => void; } export class SaveDashboardDrawer extends SceneObjectBase { diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx index 280fe65cebc..128d7f07a35 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx @@ -35,6 +35,7 @@ export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) { const result = await onSaveDashboard(dashboard, changedSaveModel, { ...options, overwrite }); if (result.status === 'success') { dashboard.closeModal(); + drawer.state.onSaveSuccess?.(); } }; diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index ce824877e79..939d8e7be95 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -325,7 +325,7 @@ export class DashboardScene extends SceneObjectBase { return true; }; - public openSaveDrawer({ saveAsCopy }: { saveAsCopy?: boolean }) { + public openSaveDrawer({ saveAsCopy, onSaveSuccess }: { saveAsCopy?: boolean; onSaveSuccess?: () => void }) { if (!this.state.isEditing) { return; } @@ -334,6 +334,7 @@ export class DashboardScene extends SceneObjectBase { overlay: new SaveDashboardDrawer({ dashboardRef: this.getRef(), saveAsCopy, + onSaveSuccess, }), }); }