mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
DashboardScene: Make sure dashboard prompt is shown when navigating away way from an edited dashboard (#84077)
DashboardScene: Make sure dashboard prompt is shown when navigating away from an edited dashboard Co-authored-by: Oscar Kilhed <oscar.kilhed@grafana.com>
This commit is contained in:
parent
22860a7978
commit
7c1ad64bb2
@ -2524,6 +2524,9 @@ exports[`better eslint`] = {
|
|||||||
"public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx:5381": [
|
"public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
[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": [
|
"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.", "0"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||||
|
@ -8,6 +8,8 @@ import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
|||||||
import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from 'app/features/dashboard/containers/types';
|
import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from 'app/features/dashboard/containers/types';
|
||||||
import { DashboardRoutes } from 'app/types';
|
import { DashboardRoutes } from 'app/types';
|
||||||
|
|
||||||
|
import { DashboardPrompt } from '../saving/DashboardPrompt';
|
||||||
|
|
||||||
import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager';
|
import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager';
|
||||||
|
|
||||||
export interface Props extends GrafanaRouteComponentProps<DashboardPageRouteParams, DashboardPageRouteSearchParams> {}
|
export interface Props extends GrafanaRouteComponentProps<DashboardPageRouteParams, DashboardPageRouteSearchParams> {}
|
||||||
@ -51,7 +53,12 @@ export function DashboardScenePage({ match, route, queryParams, history }: Props
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <dashboard.Component model={dashboard} />;
|
return (
|
||||||
|
<>
|
||||||
|
<dashboard.Component model={dashboard} />
|
||||||
|
<DashboardPrompt dashboard={dashboard} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DashboardScenePage;
|
export default DashboardScenePage;
|
||||||
|
@ -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<DashboardSceneState>) {
|
||||||
|
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;
|
||||||
|
}
|
188
public/app/features/dashboard-scene/saving/DashboardPrompt.tsx
Normal file
188
public/app/features/dashboard-scene/saving/DashboardPrompt.tsx
Normal file
@ -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<DashboardPromptState>({ 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 <Prompt when={true} message={onHistoryBlock} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={true}
|
||||||
|
title="Unsaved changes"
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
icon="exclamation-triangle"
|
||||||
|
className={styles.modal}
|
||||||
|
>
|
||||||
|
<h5>Do you want to save your changes?</h5>
|
||||||
|
<Modal.ButtonRow>
|
||||||
|
<Button variant="secondary" onClick={onDismiss} fill="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={onDiscard}>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onSaveDashboardClick}>Save dashboard</Button>
|
||||||
|
</Modal.ButtonRow>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
@ -17,6 +17,7 @@ interface SaveDashboardDrawerState extends SceneObjectState {
|
|||||||
saveTimeRange?: boolean;
|
saveTimeRange?: boolean;
|
||||||
saveVariables?: boolean;
|
saveVariables?: boolean;
|
||||||
saveAsCopy?: boolean;
|
saveAsCopy?: boolean;
|
||||||
|
onSaveSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> {
|
export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> {
|
||||||
|
@ -35,6 +35,7 @@ export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) {
|
|||||||
const result = await onSaveDashboard(dashboard, changedSaveModel, { ...options, overwrite });
|
const result = await onSaveDashboard(dashboard, changedSaveModel, { ...options, overwrite });
|
||||||
if (result.status === 'success') {
|
if (result.status === 'success') {
|
||||||
dashboard.closeModal();
|
dashboard.closeModal();
|
||||||
|
drawer.state.onSaveSuccess?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -325,7 +325,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
public openSaveDrawer({ saveAsCopy }: { saveAsCopy?: boolean }) {
|
public openSaveDrawer({ saveAsCopy, onSaveSuccess }: { saveAsCopy?: boolean; onSaveSuccess?: () => void }) {
|
||||||
if (!this.state.isEditing) {
|
if (!this.state.isEditing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -334,6 +334,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
|||||||
overlay: new SaveDashboardDrawer({
|
overlay: new SaveDashboardDrawer({
|
||||||
dashboardRef: this.getRef(),
|
dashboardRef: this.getRef(),
|
||||||
saveAsCopy,
|
saveAsCopy,
|
||||||
|
onSaveSuccess,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user