forked from grafana.jool/grafana-jool
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
253 lines
7.9 KiB
253 lines
7.9 KiB
import { locationService } from '@grafana/runtime';
|
|
import { appEvents } from 'app/core/core';
|
|
import { contextSrv } from 'app/core/services/context_srv';
|
|
import React, { useEffect, useState } from 'react';
|
|
import { Prompt } from 'react-router-dom';
|
|
import { DashboardModel } from '../../state/DashboardModel';
|
|
import { each, filter, find } from 'lodash';
|
|
import angular from 'angular';
|
|
import { UnsavedChangesModal } from '../SaveDashboard/UnsavedChangesModal';
|
|
import * as H from 'history';
|
|
import { SaveLibraryPanelModal } from 'app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal';
|
|
import { PanelModelWithLibraryPanel } from 'app/features/library-panels/types';
|
|
import { useDispatch } from 'react-redux';
|
|
import { discardPanelChanges, exitPanelEditor } from '../PanelEditor/state/actions';
|
|
import { DashboardSavedEvent } from 'app/types/events';
|
|
|
|
export interface Props {
|
|
dashboard: DashboardModel;
|
|
}
|
|
|
|
interface State {
|
|
original: object | null;
|
|
originalPath?: string;
|
|
modal: PromptModal | null;
|
|
blockedLocation?: H.Location | null;
|
|
}
|
|
|
|
enum PromptModal {
|
|
UnsavedChangesModal,
|
|
SaveLibraryPanelModal,
|
|
}
|
|
|
|
export const DashboardPrompt = React.memo(({ dashboard }: Props) => {
|
|
const [state, setState] = useState<State>({ original: null, modal: null });
|
|
const dispatch = useDispatch();
|
|
const { original, originalPath, blockedLocation, modal } = state;
|
|
|
|
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;
|
|
const original = dashboard.getSaveModelClone();
|
|
|
|
setState({ originalPath, original, modal: null });
|
|
}, 1000);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
};
|
|
}, [dashboard]);
|
|
|
|
useEffect(() => {
|
|
const handleUnload = (event: BeforeUnloadEvent) => {
|
|
if (ignoreChanges(dashboard, original)) {
|
|
return;
|
|
}
|
|
if (hasChanges(dashboard, original)) {
|
|
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, original]);
|
|
|
|
// Handle saved events
|
|
useEffect(() => {
|
|
const savedEventUnsub = appEvents.subscribe(DashboardSavedEvent, () => {
|
|
const original = dashboard.getSaveModelClone();
|
|
const originalPath = locationService.getLocation().pathname;
|
|
setState({ originalPath, original, modal: null });
|
|
|
|
if (blockedLocation) {
|
|
moveToBlockedLocationAfterReactStateUpdate(blockedLocation);
|
|
}
|
|
});
|
|
|
|
return () => savedEventUnsub.unsubscribe();
|
|
}, [dashboard, blockedLocation]);
|
|
|
|
const onHistoryBlock = (location: H.Location) => {
|
|
const panelInEdit = dashboard.panelInEdit;
|
|
const search = new URLSearchParams(location.search);
|
|
|
|
// Are we leaving panel edit & library panel?
|
|
if (panelInEdit && panelInEdit.libraryPanel && panelInEdit.hasChanged && !search.has('editPanel')) {
|
|
setState({ ...state, modal: PromptModal.SaveLibraryPanelModal, blockedLocation: location });
|
|
return false;
|
|
}
|
|
|
|
// Are we still on the same dashboard?
|
|
if (originalPath === location.pathname || !original) {
|
|
// This is here due to timing reasons we want the exit panel editor state changes to happen before router update
|
|
if (panelInEdit && !search.has('editPanel')) {
|
|
dispatch(exitPanelEditor());
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
if (ignoreChanges(dashboard, original)) {
|
|
return true;
|
|
}
|
|
|
|
if (!hasChanges(dashboard, original)) {
|
|
return true;
|
|
}
|
|
|
|
setState({ ...state, modal: PromptModal.UnsavedChangesModal, blockedLocation: location });
|
|
return false;
|
|
};
|
|
|
|
const onHideModalAndMoveToBlockedLocation = () => {
|
|
setState({ ...state, modal: null });
|
|
moveToBlockedLocationAfterReactStateUpdate(blockedLocation);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Prompt when={true} message={onHistoryBlock} />
|
|
{modal === PromptModal.UnsavedChangesModal && (
|
|
<UnsavedChangesModal
|
|
dashboard={dashboard}
|
|
onSaveSuccess={() => {}} // Handled by DashboardSavedEvent above
|
|
onDiscard={() => {
|
|
// Clear original will allow us to leave without unsaved changes prompt
|
|
setState({ ...state, original: null, modal: null });
|
|
moveToBlockedLocationAfterReactStateUpdate(blockedLocation);
|
|
}}
|
|
onDismiss={() => {
|
|
setState({ ...state, modal: null, blockedLocation: null });
|
|
}}
|
|
/>
|
|
)}
|
|
{modal === PromptModal.SaveLibraryPanelModal && (
|
|
<SaveLibraryPanelModal
|
|
isUnsavedPrompt
|
|
panel={dashboard.panelInEdit as PanelModelWithLibraryPanel}
|
|
folderId={dashboard.meta.folderId as number}
|
|
onConfirm={onHideModalAndMoveToBlockedLocation}
|
|
onDiscard={() => {
|
|
dispatch(discardPanelChanges());
|
|
setState({ ...state, modal: null });
|
|
moveToBlockedLocationAfterReactStateUpdate(blockedLocation);
|
|
}}
|
|
onDismiss={() => {
|
|
setState({ ...state, modal: null, blockedLocation: null });
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
});
|
|
|
|
function moveToBlockedLocationAfterReactStateUpdate(location?: H.Location | null) {
|
|
if (location) {
|
|
setTimeout(() => locationService.push(location!), 10);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* For some dashboards and users changes should be ignored *
|
|
*/
|
|
export function ignoreChanges(current: DashboardModel, original: object | null) {
|
|
if (!original) {
|
|
return true;
|
|
}
|
|
|
|
// Ignore changes if the user has been signed out
|
|
if (!contextSrv.isSignedIn) {
|
|
return true;
|
|
}
|
|
|
|
if (!current || !current.meta) {
|
|
return true;
|
|
}
|
|
|
|
const { canSave, fromScript, fromFile } = current.meta;
|
|
if (!contextSrv.isEditor && !canSave) {
|
|
return true;
|
|
}
|
|
|
|
return !canSave || fromScript || fromFile;
|
|
}
|
|
|
|
/**
|
|
* Remove stuff that should not count in diff
|
|
*/
|
|
function cleanDashboardFromIgnoredChanges(dashData: any) {
|
|
// need to new up the domain model class to get access to expand / collapse row logic
|
|
const model = new DashboardModel(dashData);
|
|
|
|
// Expand all rows before making comparison. This is required because row expand / collapse
|
|
// change order of panel array and panel positions.
|
|
model.expandRows();
|
|
|
|
const dash = model.getSaveModelClone();
|
|
|
|
// ignore time and refresh
|
|
dash.time = 0;
|
|
dash.refresh = 0;
|
|
dash.schemaVersion = 0;
|
|
dash.timezone = 0;
|
|
|
|
// ignore iteration property
|
|
delete dash.iteration;
|
|
|
|
dash.panels = filter(dash.panels, (panel) => {
|
|
if (panel.repeatPanelId) {
|
|
return false;
|
|
}
|
|
|
|
// remove scopedVars
|
|
panel.scopedVars = undefined;
|
|
|
|
// ignore panel legend sort
|
|
if (panel.legend) {
|
|
delete panel.legend.sort;
|
|
delete panel.legend.sortDesc;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
// ignore template variable values
|
|
each(dash.getVariables(), (variable: any) => {
|
|
variable.current = null;
|
|
variable.options = null;
|
|
variable.filters = null;
|
|
});
|
|
|
|
return dash;
|
|
}
|
|
|
|
export function hasChanges(current: DashboardModel, original: any) {
|
|
const currentClean = cleanDashboardFromIgnoredChanges(current.getSaveModelClone());
|
|
const originalClean = cleanDashboardFromIgnoredChanges(original);
|
|
|
|
const currentTimepicker: any = find((currentClean as any).nav, { type: 'timepicker' });
|
|
const originalTimepicker: any = find((originalClean as any).nav, { type: 'timepicker' });
|
|
|
|
if (currentTimepicker && originalTimepicker) {
|
|
currentTimepicker.now = originalTimepicker.now;
|
|
}
|
|
|
|
const currentJson = angular.toJson(currentClean);
|
|
const originalJson = angular.toJson(originalClean);
|
|
|
|
return currentJson !== originalJson;
|
|
}
|
|
|