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:
Dominik Prokop 2024-03-12 16:12:00 +01:00 committed by GitHub
parent 22860a7978
commit 7c1ad64bb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 359 additions and 2 deletions

View File

@ -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"]

View File

@ -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<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;

View File

@ -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;
}

View 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;
}

View File

@ -17,6 +17,7 @@ interface SaveDashboardDrawerState extends SceneObjectState {
saveTimeRange?: boolean;
saveVariables?: boolean;
saveAsCopy?: boolean;
onSaveSuccess?: () => void;
}
export class SaveDashboardDrawer extends SceneObjectBase<SaveDashboardDrawerState> {

View File

@ -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?.();
}
};

View File

@ -325,7 +325,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
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<DashboardSceneState> {
overlay: new SaveDashboardDrawer({
dashboardRef: this.getRef(),
saveAsCopy,
onSaveSuccess,
}),
});
}