ChangeTracker: Unified unsaved changes handling with library panels (#34989)

* UnsavedChanges: Move Change tracker to use Prompt

* Fix a lot of race conditions and stacking of changes in onConfirm and onDismiss

* Listen to save event

* add missing delay argument

* migrated the change tracker unit tests

* Updated snapshot

* Removed unessary action

* removed updateSourcePanel

* Fix hiding save library panel modal prompt when clicking discard

* change saved libray panel title and buttons so they are a bit different as Prompt and when used from save button

* Fixed issue with saving new dashboard

* Now all scenarios work

* increase wait time

* Fixed one more race condition
This commit is contained in:
Torkel Ödegaard 2021-06-02 12:24:19 +02:00 committed by GitHub
parent f052f10289
commit e6f2b10a36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 538 additions and 328 deletions

View File

@ -77,6 +77,7 @@ export const addDashboard = (config?: Partial<AddDashboardConfig>) => {
return e2e()
.url()
.should('contain', '/d/')
.then((url: string) => {
const uid = getDashboardUid(url);

View File

@ -1,7 +1,7 @@
import { ChangeTracker } from './ChangeTracker';
import { DashboardModel } from '../state/DashboardModel';
import { PanelModel } from '../state/PanelModel';
import { setContextSrv } from '../../../core/services/context_srv';
import { DashboardModel } from '../../state/DashboardModel';
import { PanelModel } from '../../state/PanelModel';
import { setContextSrv } from '../../../../core/services/context_srv';
import { hasChanges, ignoreChanges } from './DashboardPrompt';
function getDefaultDashboardModel(): DashboardModel {
return new DashboardModel({
@ -32,129 +32,125 @@ function getTestContext() {
const contextSrv: any = { isSignedIn: true, isEditor: true };
setContextSrv(contextSrv);
const dash: any = getDefaultDashboardModel();
const tracker = new ChangeTracker();
const original: any = dash.getSaveModelClone();
return { dash, tracker, original, contextSrv };
return { dash, original, contextSrv };
}
describe('ChangeTracker', () => {
describe('DashboardPrompt', () => {
it('No changes should not have changes', () => {
const { tracker, original, dash } = getTestContext();
expect(tracker.hasChanges(dash, original)).toBe(false);
const { original, dash } = getTestContext();
expect(hasChanges(dash, original)).toBe(false);
});
it('Simple change should be registered', () => {
const { tracker, original, dash } = getTestContext();
const { original, dash } = getTestContext();
dash.title = 'google';
expect(tracker.hasChanges(dash, original)).toBe(true);
expect(hasChanges(dash, original)).toBe(true);
});
it('Should ignore a lot of changes', () => {
const { tracker, original, dash } = getTestContext();
const { original, dash } = getTestContext();
dash.time = { from: '1h' };
dash.refresh = true;
dash.schemaVersion = 10;
expect(tracker.hasChanges(dash, original)).toBe(false);
expect(hasChanges(dash, original)).toBe(false);
});
it('Should ignore .iteration changes', () => {
const { tracker, original, dash } = getTestContext();
const { original, dash } = getTestContext();
dash.iteration = new Date().getTime() + 1;
expect(tracker.hasChanges(dash, original)).toBe(false);
expect(hasChanges(dash, original)).toBe(false);
});
it('Should ignore row collapse change', () => {
const { tracker, original, dash } = getTestContext();
const { original, dash } = getTestContext();
dash.toggleRow(dash.panels[1]);
expect(tracker.hasChanges(dash, original)).toBe(false);
expect(hasChanges(dash, original)).toBe(false);
});
it('Should ignore panel legend changes', () => {
const { tracker, original, dash } = getTestContext();
const { original, dash } = getTestContext();
dash.panels[0].legend.sortDesc = true;
dash.panels[0].legend.sort = 'avg';
expect(tracker.hasChanges(dash, original)).toBe(false);
expect(hasChanges(dash, original)).toBe(false);
});
it('Should ignore panel repeats', () => {
const { tracker, original, dash } = getTestContext();
const { original, dash } = getTestContext();
dash.panels.push(new PanelModel({ repeatPanelId: 10 }));
expect(tracker.hasChanges(dash, original)).toBe(false);
expect(hasChanges(dash, original)).toBe(false);
});
describe('ignoreChanges', () => {
describe('when called without original dashboard', () => {
it('then it should return true', () => {
const { tracker, dash } = getTestContext();
expect(tracker.ignoreChanges(dash, null)).toBe(true);
const { dash } = getTestContext();
expect(ignoreChanges(dash, null)).toBe(true);
});
});
describe('when called without current dashboard', () => {
it('then it should return true', () => {
const { tracker, original } = getTestContext();
expect(tracker.ignoreChanges((null as unknown) as DashboardModel, original)).toBe(true);
const { original } = getTestContext();
expect(ignoreChanges((null as unknown) as DashboardModel, original)).toBe(true);
});
});
describe('when called without meta in current dashboard', () => {
it('then it should return true', () => {
const { tracker, original, dash } = getTestContext();
expect(tracker.ignoreChanges({ ...dash, meta: undefined }, original)).toBe(true);
const { original, dash } = getTestContext();
expect(ignoreChanges({ ...dash, meta: undefined }, original)).toBe(true);
});
});
describe('when called for a viewer without save permissions', () => {
it('then it should return true', () => {
const { tracker, original, dash, contextSrv } = getTestContext();
const { original, dash, contextSrv } = getTestContext();
contextSrv.isEditor = false;
expect(tracker.ignoreChanges({ ...dash, meta: { canSave: false } }, original)).toBe(true);
expect(ignoreChanges({ ...dash, meta: { canSave: false } }, original)).toBe(true);
});
});
describe('when called for a viewer with save permissions', () => {
it('then it should return undefined', () => {
const { tracker, original, dash, contextSrv } = getTestContext();
const { original, dash, contextSrv } = getTestContext();
contextSrv.isEditor = false;
expect(tracker.ignoreChanges({ ...dash, meta: { canSave: true } }, original)).toBe(undefined);
expect(ignoreChanges({ ...dash, meta: { canSave: true } }, original)).toBe(undefined);
});
});
describe('when called for an user that is not signed in', () => {
it('then it should return true', () => {
const { tracker, original, dash, contextSrv } = getTestContext();
const { original, dash, contextSrv } = getTestContext();
contextSrv.isSignedIn = false;
expect(tracker.ignoreChanges({ ...dash, meta: { canSave: true } }, original)).toBe(true);
expect(ignoreChanges({ ...dash, meta: { canSave: true } }, original)).toBe(true);
});
});
describe('when called with fromScript', () => {
it('then it should return true', () => {
const { tracker, original, dash } = getTestContext();
const { original, dash } = getTestContext();
expect(
tracker.ignoreChanges({ ...dash, meta: { canSave: true, fromScript: true, fromFile: undefined } }, original)
ignoreChanges({ ...dash, meta: { canSave: true, fromScript: true, fromFile: undefined } }, original)
).toBe(true);
});
});
describe('when called with fromFile', () => {
it('then it should return true', () => {
const { tracker, original, dash } = getTestContext();
const { original, dash } = getTestContext();
expect(
tracker.ignoreChanges({ ...dash, meta: { canSave: true, fromScript: undefined, fromFile: true } }, original)
ignoreChanges({ ...dash, meta: { canSave: true, fromScript: undefined, fromFile: true } }, original)
).toBe(true);
});
});
describe('when called with canSave but without fromScript and fromFile', () => {
it('then it should return false', () => {
const { tracker, original, dash } = getTestContext();
const { original, dash } = getTestContext();
expect(
tracker.ignoreChanges(
{ ...dash, meta: { canSave: true, fromScript: undefined, fromFile: undefined } },
original
)
ignoreChanges({ ...dash, meta: { canSave: true, fromScript: undefined, fromFile: undefined } }, original)
).toBe(undefined);
});
});

View File

@ -0,0 +1,234 @@
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 } 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]);
// 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) {
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);
console.log('current', currentJson);
console.log('originalJson', originalJson);
return currentJson !== originalJson;
}

View File

@ -1,6 +1,5 @@
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { Prompt } from 'react-router-dom';
import AutoSizer from 'react-virtualized-auto-sizer';
import { css, cx } from '@emotion/css';
import { Subscription } from 'rxjs';
@ -29,14 +28,7 @@ import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPane
import { SaveDashboardModalProxy } from '../SaveDashboard/SaveDashboardModalProxy';
import { DashboardPanel } from '../../dashgrid/DashboardPanel';
import {
exitPanelEditor,
discardPanelChanges,
initPanelEditor,
panelEditorCleanUp,
updatePanelEditorUIState,
updateSourcePanel,
} from './state/actions';
import { discardPanelChanges, initPanelEditor, panelEditorCleanUp, updatePanelEditorUIState } from './state/actions';
import { updateTimeZoneForSession } from 'app/features/profile/state/reducers';
import { toggleTableView } from './state/reducers';
@ -62,6 +54,7 @@ import {
} from '../../../library-panels/utils';
import { notifyApp } from '../../../../core/actions';
import { PanelEditorTableView } from './PanelEditorTableView';
import { PanelModelWithLibraryPanel } from 'app/features/library-panels/types';
interface OwnProps {
dashboard: DashboardModel;
@ -85,8 +78,6 @@ const mapStateToProps = (state: StoreState) => {
const mapDispatchToProps = {
initPanelEditor,
exitPanelEditor,
updateSourcePanel,
panelEditorCleanUp,
discardPanelChanges,
updatePanelEditorUIState,
@ -99,9 +90,17 @@ const connector = connect(mapStateToProps, mapDispatchToProps);
type Props = OwnProps & ConnectedProps<typeof connector>;
interface State {
showSaveLibraryPanelModal?: boolean;
}
export class PanelEditorUnconnected extends PureComponent<Props> {
private eventSubs?: Subscription;
state: State = {
showSaveLibraryPanelModal: false,
};
componentDidMount() {
this.props.initPanelEditor(this.props.sourcePanel, this.props.dashboard);
}
@ -164,7 +163,6 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
) {
try {
await saveAndRefreshLibraryPanel(this.props.panel, this.props.dashboard.meta.folderId!);
this.props.updateSourcePanel(this.props.panel);
this.props.notifyApp(createPanelLibrarySuccessNotification('Library panel saved'));
} catch (err) {
this.props.notifyApp(createPanelLibraryErrorNotification(`Error saving library panel: "${err.statusText}"`));
@ -172,22 +170,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
return;
}
appEvents.publish(
new ShowModalReactEvent({
component: SaveLibraryPanelModal,
props: {
panel: this.props.panel,
folderId: this.props.dashboard.meta.folderId,
isOpen: true,
onConfirm: () => {
// need to update the source panel here so that when
// the user exits the panel editor they aren't prompted to save again
this.props.updateSourcePanel(this.props.panel);
},
onDiscard: this.onDiscard,
},
})
);
this.setState({ showSaveLibraryPanelModal: true });
};
onChangeTab = (tab: PanelEditorTab) => {
@ -428,8 +411,16 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
);
}
onGoBackToDashboard = () => {
locationService.partial({ editPanel: null, tab: null });
};
onConfirmAndDismissLibarayPanelModel = () => {
this.setState({ showSaveLibraryPanelModal: false });
};
render() {
const { dashboard, initDone, updatePanelEditorUIState, uiState, exitPanelEditor } = this.props;
const { dashboard, initDone, updatePanelEditorUIState, uiState } = this.props;
const styles = getStyles(config.theme, this.props);
if (!initDone) {
@ -438,19 +429,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
return (
<div className={styles.wrapper} aria-label={selectors.components.PanelEditor.General.content}>
<Prompt
when={true}
message={(location) => {
const searchParams = new URLSearchParams(location.search);
if (!this.props.panel.libraryPanel || !this.props.panel.hasChanged || searchParams.has('editPanel')) {
return true;
}
exitPanelEditor();
return false;
}}
/>
<PageToolbar title={`${dashboard.title} / Edit Panel`} onGoBack={exitPanelEditor}>
<PageToolbar title={`${dashboard.title} / Edit Panel`} onGoBack={this.onGoBackToDashboard}>
{this.renderEditorActions()}
</PageToolbar>
<div className={styles.verticalSplitPanesWrapper}>
@ -462,6 +441,15 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
rightPaneVisible={uiState.isPanelOptionsVisible}
/>
</div>
{this.state.showSaveLibraryPanelModal && (
<SaveLibraryPanelModal
panel={this.props.panel as PanelModelWithLibraryPanel}
folderId={this.props.dashboard.meta.folderId as number}
onConfirm={this.onConfirmAndDismissLibarayPanelModel}
onDiscard={this.onDiscard}
onDismiss={this.onConfirmAndDismissLibarayPanelModel}
/>
)}
</div>
);
}

View File

@ -1,7 +1,5 @@
import { DashboardModel, PanelModel } from '../../../state';
import { ThunkResult } from 'app/types';
import { appEvents } from 'app/core/core';
import { SaveLibraryPanelModal } from 'app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal';
import {
closeCompleted,
PANEL_EDITOR_UI_STATE_STORAGE_KEY,
@ -14,7 +12,6 @@ import { cleanUpEditPanel, panelModelAndPluginReady } from '../../../state/reduc
import store from 'app/core/store';
import { pick } from 'lodash';
import { locationService } from '@grafana/runtime';
import { ShowModalReactEvent } from '../../../../../types/events';
export function initPanelEditor(sourcePanel: PanelModel, dashboard: DashboardModel): ThunkResult<void> {
return (dispatch) => {
@ -29,19 +26,6 @@ export function initPanelEditor(sourcePanel: PanelModel, dashboard: DashboardMod
};
}
export function updateSourcePanel(sourcePanel: PanelModel): ThunkResult<void> {
return (dispatch, getStore) => {
const { getPanel } = getStore().panelEditor;
dispatch(
updateEditorInitState({
panel: getPanel(),
sourcePanel,
})
);
};
}
export function discardPanelChanges(): ThunkResult<void> {
return async (dispatch, getStore) => {
const { getPanel } = getStore().panelEditor;
@ -52,43 +36,12 @@ export function discardPanelChanges(): ThunkResult<void> {
export function exitPanelEditor(): ThunkResult<void> {
return async (dispatch, getStore) => {
const dashboard = getStore().dashboard.getModel();
const { getPanel, shouldDiscardChanges } = getStore().panelEditor;
const onConfirm = () => locationService.partial({ editPanel: null, tab: null });
const panel = getPanel();
const onDiscard = () => {
dispatch(discardPanelChanges());
onConfirm();
};
if (shouldDiscardChanges || !panel.libraryPanel) {
onConfirm();
return;
}
if (!panel.hasChanged) {
onConfirm();
return;
}
appEvents.publish(
new ShowModalReactEvent({
component: SaveLibraryPanelModal,
props: {
panel,
folderId: dashboard!.meta.folderId,
isOpen: true,
onConfirm,
onDiscard,
},
})
);
locationService.partial({ editPanel: null, tab: null });
};
}
function updateDuplicateLibraryPanels(modifiedPanel: PanelModel, dashboard: DashboardModel, dispatch: any) {
if (modifiedPanel.libraryPanel?.uid === undefined) {
function updateDuplicateLibraryPanels(modifiedPanel: PanelModel, dashboard: DashboardModel | null, dispatch: any) {
if (modifiedPanel.libraryPanel?.uid === undefined || !dashboard) {
return;
}
@ -132,7 +85,7 @@ export function panelEditorCleanUp(): ThunkResult<void> {
const sourcePanel = getSourcePanel();
const panelTypeChanged = sourcePanel.type !== panel.type;
updateDuplicateLibraryPanels(panel, dashboard!, dispatch);
updateDuplicateLibraryPanels(panel, dashboard, dispatch);
// restore the source panel ID before we update source panel
modifiedSaveModel.id = sourcePanel.id;

View File

@ -32,13 +32,7 @@ export const UnsavedChangesModal: React.FC<UnsavedChangesModalProps> = ({
<Button variant="secondary" onClick={onDismiss} fill="outline">
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
onDiscard();
onDismiss();
}}
>
<Button variant="destructive" onClick={onDiscard}>
Discard
</Button>
<SaveDashboardButton dashboard={dashboard} onSaveSuccess={onSaveSuccess} />

View File

@ -31,12 +31,11 @@ export const useDashboardSave = (dashboard: DashboardModel) => {
appEvents.publish(new DashboardSavedEvent());
appEvents.emit(AppEvents.alertSuccess, ['Dashboard saved']);
// Using global locationService because save modals are rendered as a separate React tree
const currentPath = locationService.getLocation().pathname;
const newUrl = locationUtil.stripBaseFromUrl(state.value.url);
if (newUrl !== currentPath) {
locationService.replace(newUrl);
setTimeout(() => locationService.replace(newUrl));
}
}
}, [dashboard, state]);

View File

@ -29,6 +29,7 @@ import { getKioskMode } from 'app/core/navigation/kiosk';
import { GrafanaTheme2, UrlQueryValue } from '@grafana/data';
import { DashboardLoading } from '../components/DashboardLoading/DashboardLoading';
import { DashboardFailed } from '../components/DashboardLoading/DashboardFailed';
import { DashboardPrompt } from '../components/DashboardPrompt/DashboardPrompt';
export interface DashboardPageRouteParams {
uid?: string;
@ -320,6 +321,8 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
</div>
)}
<DashboardPrompt dashboard={dashboard} />
<div className={styles.dashboardScroll}>
<CustomScrollbar
autoHeightMin="100%"

View File

@ -119,6 +119,113 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
title="My dashboard"
/>
</div>
<Memo()
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": EventBusSrv {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"formatDate": [Function],
"getVariables": [Function],
"getVariablesFromState": [Function],
"gnetId": null,
"graphTooltip": 0,
"hasChangesThatAffectsAllPanels": false,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"hasUnsavedFolderChange": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"configRev": 0,
"datasource": null,
"events": EventBusSrv {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"fieldConfig": Object {
"defaults": Object {
"mappings": undefined,
},
"overrides": Array [],
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"isEditing": false,
"isInView": false,
"isViewing": false,
"options": Object {},
"replaceVariables": [Function],
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 30,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
/>
<div
className="css-17x4n39"
>
@ -499,6 +606,113 @@ exports[`DashboardPage When dashboard has editview url state should render setti
title="My dashboard"
/>
</div>
<Memo()
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": EventBusSrv {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"formatDate": [Function],
"getVariables": [Function],
"getVariablesFromState": [Function],
"gnetId": null,
"graphTooltip": 0,
"hasChangesThatAffectsAllPanels": false,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"hasUnsavedFolderChange": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"configRev": 0,
"datasource": null,
"events": EventBusSrv {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"fieldConfig": Object {
"defaults": Object {
"mappings": undefined,
},
"overrides": Array [],
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"isEditing": false,
"isInView": false,
"isViewing": false,
"options": Object {},
"replaceVariables": [Function],
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 30,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
/>
<div
className="css-17x4n39"
>

View File

@ -1,161 +0,0 @@
import { each, filter, find } from 'lodash';
import { DashboardModel } from '../state/DashboardModel';
import { contextSrv } from 'app/core/services/context_srv';
import { appEvents } from 'app/core/app_events';
import { UnsavedChangesModal } from '../components/SaveDashboard/UnsavedChangesModal';
import { DashboardSavedEvent, ShowModalReactEvent } from '../../../types/events';
import { locationService } from '@grafana/runtime';
import angular from 'angular';
export class ChangeTracker {
init(dashboard: DashboardModel, originalCopyDelay: number) {
let original: object | null = null;
let originalPath = locationService.getLocation().pathname;
// register events
const savedEventUnsub = appEvents.subscribe(DashboardSavedEvent, () => {
original = dashboard.getSaveModelClone();
originalPath = locationService.getLocation().pathname;
});
if (originalCopyDelay && !dashboard.meta.fromExplore) {
setTimeout(() => {
// wait for different services to patch the dashboard (missing properties)
original = dashboard.getSaveModelClone();
}, originalCopyDelay);
} else {
original = dashboard.getSaveModelClone();
}
const history = locationService.getHistory();
const blockUnsub = history.block((location) => {
if (originalPath === location.pathname) {
return;
}
if (this.ignoreChanges(dashboard, original)) {
return;
}
if (!this.hasChanges(dashboard, original!)) {
return;
}
appEvents.publish(
new ShowModalReactEvent({
component: UnsavedChangesModal,
props: {
dashboard: dashboard,
onSaveSuccess: () => {
original = dashboard.getSaveModelClone();
history.push(location);
},
onDiscard: () => {
original = dashboard.getSaveModelClone();
history.push(location);
},
},
})
);
return false;
});
const historyListenUnsub = history.listen((location) => {
if (originalPath !== location.pathname) {
blockUnsub();
historyListenUnsub();
savedEventUnsub.unsubscribe();
}
});
}
// for some dashboards and users
// changes should be ignored
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
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;
}
hasChanges(current: DashboardModel, original: any) {
const currentClean = this.cleanDashboardFromIgnoredChanges(current.getSaveModelClone());
const originalClean = this.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;
}
}

View File

@ -1,9 +0,0 @@
import { DashboardModel } from '../../state/DashboardModel';
export class ChangeTracker {
initCalled = false;
init(dashboard: DashboardModel, originalCopyDelay: number) {
this.initCalled = true;
}
}

View File

@ -374,10 +374,10 @@ export class DashboardModel {
}
exitPanelEditor() {
getTimeSrv().resumeAutoRefresh();
this.panelInEdit!.destroy();
this.panelInEdit = undefined;
this.refreshIfChangeAffectsAllPanels();
getTimeSrv().resumeAutoRefresh();
}
setChangeAffectsAllPanels() {

View File

@ -38,7 +38,6 @@ jest.mock('app/core/services/context_srv', () => ({
user: { orgId: 1, orgName: 'TestOrg' },
},
}));
jest.mock('app/features/dashboard/services/ChangeTracker');
variableAdapters.register(createConstantVariableAdapter());
const mockStore = configureMockStore([thunk]);

View File

@ -23,7 +23,6 @@ import { initVariablesTransaction } from '../../variables/state/actions';
import { emitDashboardViewEvent } from './analyticsProcessor';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { locationService } from '@grafana/runtime';
import { ChangeTracker } from '../services/ChangeTracker';
import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner';
export interface InitDashboardArgs {
@ -174,7 +173,6 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
// init services
const timeSrv: TimeSrv = getTimeSrv();
const dashboardSrv: DashboardSrv = getDashboardSrv();
const changeTracker = new ChangeTracker();
timeSrv.init(dashboard);
const runner = createDashboardQueryRunner({ dashboard, timeSrv });
@ -208,7 +206,6 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
dashboard.autoFitPanels(window.innerHeight, queryParams.kiosk);
}
changeTracker.init(dashboard, 2000);
keybindingSrv.setupDashboardBindings(dashboard);
} catch (err) {
dispatch(notifyApp(createErrorNotification('Dashboard init failed', err)));

View File

@ -9,7 +9,7 @@ import { getModalStyles } from '../../styles';
interface Props {
panel: PanelModelWithLibraryPanel;
folderId: number;
isOpen: boolean;
isUnsavedPrompt?: boolean;
onConfirm: () => void;
onDismiss: () => void;
onDiscard: () => void;
@ -18,7 +18,7 @@ interface Props {
export const SaveLibraryPanelModal: React.FC<Props> = ({
panel,
folderId,
isOpen,
isUnsavedPrompt,
onDismiss,
onConfirm,
onDiscard,
@ -52,11 +52,12 @@ export const SaveLibraryPanelModal: React.FC<Props> = ({
const styles = useStyles(getModalStyles);
const discardAndClose = useCallback(() => {
onDiscard();
onDismiss();
}, [onDiscard, onDismiss]);
}, [onDiscard]);
const title = isUnsavedPrompt ? 'Unsaved library panel changes' : 'Save library panel';
return (
<Modal title="Update all panel instances" icon="save" onDismiss={onDismiss} isOpen={isOpen}>
<Modal title={title} icon="save" onDismiss={onDismiss} isOpen={true}>
<div>
<p className={styles.textInfo}>
{'This update will affect '}
@ -95,14 +96,15 @@ export const SaveLibraryPanelModal: React.FC<Props> = ({
<Button variant="secondary" onClick={onDismiss} fill="outline">
Cancel
</Button>
<Button variant="destructive" onClick={discardAndClose}>
Discard
</Button>
{isUnsavedPrompt && (
<Button variant="destructive" onClick={discardAndClose}>
Discard
</Button>
)}
<Button
onClick={() => {
saveLibraryPanel(panel, folderId).then(() => {
onConfirm();
onDismiss();
});
}}
>