mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
f052f10289
commit
e6f2b10a36
@ -77,6 +77,7 @@ export const addDashboard = (config?: Partial<AddDashboardConfig>) => {
|
|||||||
|
|
||||||
return e2e()
|
return e2e()
|
||||||
.url()
|
.url()
|
||||||
|
.should('contain', '/d/')
|
||||||
.then((url: string) => {
|
.then((url: string) => {
|
||||||
const uid = getDashboardUid(url);
|
const uid = getDashboardUid(url);
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ChangeTracker } from './ChangeTracker';
|
import { DashboardModel } from '../../state/DashboardModel';
|
||||||
import { DashboardModel } from '../state/DashboardModel';
|
import { PanelModel } from '../../state/PanelModel';
|
||||||
import { PanelModel } from '../state/PanelModel';
|
import { setContextSrv } from '../../../../core/services/context_srv';
|
||||||
import { setContextSrv } from '../../../core/services/context_srv';
|
import { hasChanges, ignoreChanges } from './DashboardPrompt';
|
||||||
|
|
||||||
function getDefaultDashboardModel(): DashboardModel {
|
function getDefaultDashboardModel(): DashboardModel {
|
||||||
return new DashboardModel({
|
return new DashboardModel({
|
||||||
@ -32,129 +32,125 @@ function getTestContext() {
|
|||||||
const contextSrv: any = { isSignedIn: true, isEditor: true };
|
const contextSrv: any = { isSignedIn: true, isEditor: true };
|
||||||
setContextSrv(contextSrv);
|
setContextSrv(contextSrv);
|
||||||
const dash: any = getDefaultDashboardModel();
|
const dash: any = getDefaultDashboardModel();
|
||||||
const tracker = new ChangeTracker();
|
|
||||||
const original: any = dash.getSaveModelClone();
|
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', () => {
|
it('No changes should not have changes', () => {
|
||||||
const { tracker, original, dash } = getTestContext();
|
const { original, dash } = getTestContext();
|
||||||
expect(tracker.hasChanges(dash, original)).toBe(false);
|
expect(hasChanges(dash, original)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Simple change should be registered', () => {
|
it('Simple change should be registered', () => {
|
||||||
const { tracker, original, dash } = getTestContext();
|
const { original, dash } = getTestContext();
|
||||||
dash.title = 'google';
|
dash.title = 'google';
|
||||||
expect(tracker.hasChanges(dash, original)).toBe(true);
|
expect(hasChanges(dash, original)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should ignore a lot of changes', () => {
|
it('Should ignore a lot of changes', () => {
|
||||||
const { tracker, original, dash } = getTestContext();
|
const { original, dash } = getTestContext();
|
||||||
dash.time = { from: '1h' };
|
dash.time = { from: '1h' };
|
||||||
dash.refresh = true;
|
dash.refresh = true;
|
||||||
dash.schemaVersion = 10;
|
dash.schemaVersion = 10;
|
||||||
expect(tracker.hasChanges(dash, original)).toBe(false);
|
expect(hasChanges(dash, original)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should ignore .iteration changes', () => {
|
it('Should ignore .iteration changes', () => {
|
||||||
const { tracker, original, dash } = getTestContext();
|
const { original, dash } = getTestContext();
|
||||||
dash.iteration = new Date().getTime() + 1;
|
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', () => {
|
it('Should ignore row collapse change', () => {
|
||||||
const { tracker, original, dash } = getTestContext();
|
const { original, dash } = getTestContext();
|
||||||
dash.toggleRow(dash.panels[1]);
|
dash.toggleRow(dash.panels[1]);
|
||||||
expect(tracker.hasChanges(dash, original)).toBe(false);
|
expect(hasChanges(dash, original)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should ignore panel legend changes', () => {
|
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.sortDesc = true;
|
||||||
dash.panels[0].legend.sort = 'avg';
|
dash.panels[0].legend.sort = 'avg';
|
||||||
expect(tracker.hasChanges(dash, original)).toBe(false);
|
expect(hasChanges(dash, original)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should ignore panel repeats', () => {
|
it('Should ignore panel repeats', () => {
|
||||||
const { tracker, original, dash } = getTestContext();
|
const { original, dash } = getTestContext();
|
||||||
dash.panels.push(new PanelModel({ repeatPanelId: 10 }));
|
dash.panels.push(new PanelModel({ repeatPanelId: 10 }));
|
||||||
expect(tracker.hasChanges(dash, original)).toBe(false);
|
expect(hasChanges(dash, original)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ignoreChanges', () => {
|
describe('ignoreChanges', () => {
|
||||||
describe('when called without original dashboard', () => {
|
describe('when called without original dashboard', () => {
|
||||||
it('then it should return true', () => {
|
it('then it should return true', () => {
|
||||||
const { tracker, dash } = getTestContext();
|
const { dash } = getTestContext();
|
||||||
expect(tracker.ignoreChanges(dash, null)).toBe(true);
|
expect(ignoreChanges(dash, null)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when called without current dashboard', () => {
|
describe('when called without current dashboard', () => {
|
||||||
it('then it should return true', () => {
|
it('then it should return true', () => {
|
||||||
const { tracker, original } = getTestContext();
|
const { original } = getTestContext();
|
||||||
expect(tracker.ignoreChanges((null as unknown) as DashboardModel, original)).toBe(true);
|
expect(ignoreChanges((null as unknown) as DashboardModel, original)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when called without meta in current dashboard', () => {
|
describe('when called without meta in current dashboard', () => {
|
||||||
it('then it should return true', () => {
|
it('then it should return true', () => {
|
||||||
const { tracker, original, dash } = getTestContext();
|
const { original, dash } = getTestContext();
|
||||||
expect(tracker.ignoreChanges({ ...dash, meta: undefined }, original)).toBe(true);
|
expect(ignoreChanges({ ...dash, meta: undefined }, original)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when called for a viewer without save permissions', () => {
|
describe('when called for a viewer without save permissions', () => {
|
||||||
it('then it should return true', () => {
|
it('then it should return true', () => {
|
||||||
const { tracker, original, dash, contextSrv } = getTestContext();
|
const { original, dash, contextSrv } = getTestContext();
|
||||||
contextSrv.isEditor = false;
|
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', () => {
|
describe('when called for a viewer with save permissions', () => {
|
||||||
it('then it should return undefined', () => {
|
it('then it should return undefined', () => {
|
||||||
const { tracker, original, dash, contextSrv } = getTestContext();
|
const { original, dash, contextSrv } = getTestContext();
|
||||||
contextSrv.isEditor = false;
|
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', () => {
|
describe('when called for an user that is not signed in', () => {
|
||||||
it('then it should return true', () => {
|
it('then it should return true', () => {
|
||||||
const { tracker, original, dash, contextSrv } = getTestContext();
|
const { original, dash, contextSrv } = getTestContext();
|
||||||
contextSrv.isSignedIn = false;
|
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', () => {
|
describe('when called with fromScript', () => {
|
||||||
it('then it should return true', () => {
|
it('then it should return true', () => {
|
||||||
const { tracker, original, dash } = getTestContext();
|
const { original, dash } = getTestContext();
|
||||||
expect(
|
expect(
|
||||||
tracker.ignoreChanges({ ...dash, meta: { canSave: true, fromScript: true, fromFile: undefined } }, original)
|
ignoreChanges({ ...dash, meta: { canSave: true, fromScript: true, fromFile: undefined } }, original)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when called with fromFile', () => {
|
describe('when called with fromFile', () => {
|
||||||
it('then it should return true', () => {
|
it('then it should return true', () => {
|
||||||
const { tracker, original, dash } = getTestContext();
|
const { original, dash } = getTestContext();
|
||||||
expect(
|
expect(
|
||||||
tracker.ignoreChanges({ ...dash, meta: { canSave: true, fromScript: undefined, fromFile: true } }, original)
|
ignoreChanges({ ...dash, meta: { canSave: true, fromScript: undefined, fromFile: true } }, original)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when called with canSave but without fromScript and fromFile', () => {
|
describe('when called with canSave but without fromScript and fromFile', () => {
|
||||||
it('then it should return false', () => {
|
it('then it should return false', () => {
|
||||||
const { tracker, original, dash } = getTestContext();
|
const { original, dash } = getTestContext();
|
||||||
expect(
|
expect(
|
||||||
tracker.ignoreChanges(
|
ignoreChanges({ ...dash, meta: { canSave: true, fromScript: undefined, fromFile: undefined } }, original)
|
||||||
{ ...dash, meta: { canSave: true, fromScript: undefined, fromFile: undefined } },
|
|
||||||
original
|
|
||||||
)
|
|
||||||
).toBe(undefined);
|
).toBe(undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
@ -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;
|
||||||
|
}
|
@ -1,6 +1,5 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import { connect, ConnectedProps } from 'react-redux';
|
import { connect, ConnectedProps } from 'react-redux';
|
||||||
import { Prompt } from 'react-router-dom';
|
|
||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
@ -29,14 +28,7 @@ import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPane
|
|||||||
import { SaveDashboardModalProxy } from '../SaveDashboard/SaveDashboardModalProxy';
|
import { SaveDashboardModalProxy } from '../SaveDashboard/SaveDashboardModalProxy';
|
||||||
import { DashboardPanel } from '../../dashgrid/DashboardPanel';
|
import { DashboardPanel } from '../../dashgrid/DashboardPanel';
|
||||||
|
|
||||||
import {
|
import { discardPanelChanges, initPanelEditor, panelEditorCleanUp, updatePanelEditorUIState } from './state/actions';
|
||||||
exitPanelEditor,
|
|
||||||
discardPanelChanges,
|
|
||||||
initPanelEditor,
|
|
||||||
panelEditorCleanUp,
|
|
||||||
updatePanelEditorUIState,
|
|
||||||
updateSourcePanel,
|
|
||||||
} from './state/actions';
|
|
||||||
|
|
||||||
import { updateTimeZoneForSession } from 'app/features/profile/state/reducers';
|
import { updateTimeZoneForSession } from 'app/features/profile/state/reducers';
|
||||||
import { toggleTableView } from './state/reducers';
|
import { toggleTableView } from './state/reducers';
|
||||||
@ -62,6 +54,7 @@ import {
|
|||||||
} from '../../../library-panels/utils';
|
} from '../../../library-panels/utils';
|
||||||
import { notifyApp } from '../../../../core/actions';
|
import { notifyApp } from '../../../../core/actions';
|
||||||
import { PanelEditorTableView } from './PanelEditorTableView';
|
import { PanelEditorTableView } from './PanelEditorTableView';
|
||||||
|
import { PanelModelWithLibraryPanel } from 'app/features/library-panels/types';
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
dashboard: DashboardModel;
|
dashboard: DashboardModel;
|
||||||
@ -85,8 +78,6 @@ const mapStateToProps = (state: StoreState) => {
|
|||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
initPanelEditor,
|
initPanelEditor,
|
||||||
exitPanelEditor,
|
|
||||||
updateSourcePanel,
|
|
||||||
panelEditorCleanUp,
|
panelEditorCleanUp,
|
||||||
discardPanelChanges,
|
discardPanelChanges,
|
||||||
updatePanelEditorUIState,
|
updatePanelEditorUIState,
|
||||||
@ -99,9 +90,17 @@ const connector = connect(mapStateToProps, mapDispatchToProps);
|
|||||||
|
|
||||||
type Props = OwnProps & ConnectedProps<typeof connector>;
|
type Props = OwnProps & ConnectedProps<typeof connector>;
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
showSaveLibraryPanelModal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class PanelEditorUnconnected extends PureComponent<Props> {
|
export class PanelEditorUnconnected extends PureComponent<Props> {
|
||||||
private eventSubs?: Subscription;
|
private eventSubs?: Subscription;
|
||||||
|
|
||||||
|
state: State = {
|
||||||
|
showSaveLibraryPanelModal: false,
|
||||||
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.props.initPanelEditor(this.props.sourcePanel, this.props.dashboard);
|
this.props.initPanelEditor(this.props.sourcePanel, this.props.dashboard);
|
||||||
}
|
}
|
||||||
@ -164,7 +163,6 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
await saveAndRefreshLibraryPanel(this.props.panel, this.props.dashboard.meta.folderId!);
|
await saveAndRefreshLibraryPanel(this.props.panel, this.props.dashboard.meta.folderId!);
|
||||||
this.props.updateSourcePanel(this.props.panel);
|
|
||||||
this.props.notifyApp(createPanelLibrarySuccessNotification('Library panel saved'));
|
this.props.notifyApp(createPanelLibrarySuccessNotification('Library panel saved'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.props.notifyApp(createPanelLibraryErrorNotification(`Error saving library panel: "${err.statusText}"`));
|
this.props.notifyApp(createPanelLibraryErrorNotification(`Error saving library panel: "${err.statusText}"`));
|
||||||
@ -172,22 +170,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
appEvents.publish(
|
this.setState({ showSaveLibraryPanelModal: true });
|
||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onChangeTab = (tab: PanelEditorTab) => {
|
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() {
|
render() {
|
||||||
const { dashboard, initDone, updatePanelEditorUIState, uiState, exitPanelEditor } = this.props;
|
const { dashboard, initDone, updatePanelEditorUIState, uiState } = this.props;
|
||||||
const styles = getStyles(config.theme, this.props);
|
const styles = getStyles(config.theme, this.props);
|
||||||
|
|
||||||
if (!initDone) {
|
if (!initDone) {
|
||||||
@ -438,19 +429,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper} aria-label={selectors.components.PanelEditor.General.content}>
|
<div className={styles.wrapper} aria-label={selectors.components.PanelEditor.General.content}>
|
||||||
<Prompt
|
<PageToolbar title={`${dashboard.title} / Edit Panel`} onGoBack={this.onGoBackToDashboard}>
|
||||||
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}>
|
|
||||||
{this.renderEditorActions()}
|
{this.renderEditorActions()}
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
<div className={styles.verticalSplitPanesWrapper}>
|
<div className={styles.verticalSplitPanesWrapper}>
|
||||||
@ -462,6 +441,15 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
|||||||
rightPaneVisible={uiState.isPanelOptionsVisible}
|
rightPaneVisible={uiState.isPanelOptionsVisible}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import { DashboardModel, PanelModel } from '../../../state';
|
import { DashboardModel, PanelModel } from '../../../state';
|
||||||
import { ThunkResult } from 'app/types';
|
import { ThunkResult } from 'app/types';
|
||||||
import { appEvents } from 'app/core/core';
|
|
||||||
import { SaveLibraryPanelModal } from 'app/features/library-panels/components/SaveLibraryPanelModal/SaveLibraryPanelModal';
|
|
||||||
import {
|
import {
|
||||||
closeCompleted,
|
closeCompleted,
|
||||||
PANEL_EDITOR_UI_STATE_STORAGE_KEY,
|
PANEL_EDITOR_UI_STATE_STORAGE_KEY,
|
||||||
@ -14,7 +12,6 @@ import { cleanUpEditPanel, panelModelAndPluginReady } from '../../../state/reduc
|
|||||||
import store from 'app/core/store';
|
import store from 'app/core/store';
|
||||||
import { pick } from 'lodash';
|
import { pick } from 'lodash';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import { ShowModalReactEvent } from '../../../../../types/events';
|
|
||||||
|
|
||||||
export function initPanelEditor(sourcePanel: PanelModel, dashboard: DashboardModel): ThunkResult<void> {
|
export function initPanelEditor(sourcePanel: PanelModel, dashboard: DashboardModel): ThunkResult<void> {
|
||||||
return (dispatch) => {
|
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> {
|
export function discardPanelChanges(): ThunkResult<void> {
|
||||||
return async (dispatch, getStore) => {
|
return async (dispatch, getStore) => {
|
||||||
const { getPanel } = getStore().panelEditor;
|
const { getPanel } = getStore().panelEditor;
|
||||||
@ -52,43 +36,12 @@ export function discardPanelChanges(): ThunkResult<void> {
|
|||||||
|
|
||||||
export function exitPanelEditor(): ThunkResult<void> {
|
export function exitPanelEditor(): ThunkResult<void> {
|
||||||
return async (dispatch, getStore) => {
|
return async (dispatch, getStore) => {
|
||||||
const dashboard = getStore().dashboard.getModel();
|
locationService.partial({ editPanel: null, tab: null });
|
||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDuplicateLibraryPanels(modifiedPanel: PanelModel, dashboard: DashboardModel, dispatch: any) {
|
function updateDuplicateLibraryPanels(modifiedPanel: PanelModel, dashboard: DashboardModel | null, dispatch: any) {
|
||||||
if (modifiedPanel.libraryPanel?.uid === undefined) {
|
if (modifiedPanel.libraryPanel?.uid === undefined || !dashboard) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,7 +85,7 @@ export function panelEditorCleanUp(): ThunkResult<void> {
|
|||||||
const sourcePanel = getSourcePanel();
|
const sourcePanel = getSourcePanel();
|
||||||
const panelTypeChanged = sourcePanel.type !== panel.type;
|
const panelTypeChanged = sourcePanel.type !== panel.type;
|
||||||
|
|
||||||
updateDuplicateLibraryPanels(panel, dashboard!, dispatch);
|
updateDuplicateLibraryPanels(panel, dashboard, dispatch);
|
||||||
|
|
||||||
// restore the source panel ID before we update source panel
|
// restore the source panel ID before we update source panel
|
||||||
modifiedSaveModel.id = sourcePanel.id;
|
modifiedSaveModel.id = sourcePanel.id;
|
||||||
|
@ -32,13 +32,7 @@ export const UnsavedChangesModal: React.FC<UnsavedChangesModalProps> = ({
|
|||||||
<Button variant="secondary" onClick={onDismiss} fill="outline">
|
<Button variant="secondary" onClick={onDismiss} fill="outline">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="destructive" onClick={onDiscard}>
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
onDiscard();
|
|
||||||
onDismiss();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Discard
|
Discard
|
||||||
</Button>
|
</Button>
|
||||||
<SaveDashboardButton dashboard={dashboard} onSaveSuccess={onSaveSuccess} />
|
<SaveDashboardButton dashboard={dashboard} onSaveSuccess={onSaveSuccess} />
|
||||||
|
@ -31,12 +31,11 @@ export const useDashboardSave = (dashboard: DashboardModel) => {
|
|||||||
appEvents.publish(new DashboardSavedEvent());
|
appEvents.publish(new DashboardSavedEvent());
|
||||||
appEvents.emit(AppEvents.alertSuccess, ['Dashboard saved']);
|
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 currentPath = locationService.getLocation().pathname;
|
||||||
const newUrl = locationUtil.stripBaseFromUrl(state.value.url);
|
const newUrl = locationUtil.stripBaseFromUrl(state.value.url);
|
||||||
|
|
||||||
if (newUrl !== currentPath) {
|
if (newUrl !== currentPath) {
|
||||||
locationService.replace(newUrl);
|
setTimeout(() => locationService.replace(newUrl));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [dashboard, state]);
|
}, [dashboard, state]);
|
||||||
|
@ -29,6 +29,7 @@ import { getKioskMode } from 'app/core/navigation/kiosk';
|
|||||||
import { GrafanaTheme2, UrlQueryValue } from '@grafana/data';
|
import { GrafanaTheme2, UrlQueryValue } from '@grafana/data';
|
||||||
import { DashboardLoading } from '../components/DashboardLoading/DashboardLoading';
|
import { DashboardLoading } from '../components/DashboardLoading/DashboardLoading';
|
||||||
import { DashboardFailed } from '../components/DashboardLoading/DashboardFailed';
|
import { DashboardFailed } from '../components/DashboardLoading/DashboardFailed';
|
||||||
|
import { DashboardPrompt } from '../components/DashboardPrompt/DashboardPrompt';
|
||||||
|
|
||||||
export interface DashboardPageRouteParams {
|
export interface DashboardPageRouteParams {
|
||||||
uid?: string;
|
uid?: string;
|
||||||
@ -320,6 +321,8 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DashboardPrompt dashboard={dashboard} />
|
||||||
|
|
||||||
<div className={styles.dashboardScroll}>
|
<div className={styles.dashboardScroll}>
|
||||||
<CustomScrollbar
|
<CustomScrollbar
|
||||||
autoHeightMin="100%"
|
autoHeightMin="100%"
|
||||||
|
@ -119,6 +119,113 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
|
|||||||
title="My dashboard"
|
title="My dashboard"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<div
|
||||||
className="css-17x4n39"
|
className="css-17x4n39"
|
||||||
>
|
>
|
||||||
@ -499,6 +606,113 @@ exports[`DashboardPage When dashboard has editview url state should render setti
|
|||||||
title="My dashboard"
|
title="My dashboard"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<div
|
||||||
className="css-17x4n39"
|
className="css-17x4n39"
|
||||||
>
|
>
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
import { DashboardModel } from '../../state/DashboardModel';
|
|
||||||
|
|
||||||
export class ChangeTracker {
|
|
||||||
initCalled = false;
|
|
||||||
|
|
||||||
init(dashboard: DashboardModel, originalCopyDelay: number) {
|
|
||||||
this.initCalled = true;
|
|
||||||
}
|
|
||||||
}
|
|
@ -374,10 +374,10 @@ export class DashboardModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exitPanelEditor() {
|
exitPanelEditor() {
|
||||||
getTimeSrv().resumeAutoRefresh();
|
|
||||||
this.panelInEdit!.destroy();
|
this.panelInEdit!.destroy();
|
||||||
this.panelInEdit = undefined;
|
this.panelInEdit = undefined;
|
||||||
this.refreshIfChangeAffectsAllPanels();
|
this.refreshIfChangeAffectsAllPanels();
|
||||||
|
getTimeSrv().resumeAutoRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
setChangeAffectsAllPanels() {
|
setChangeAffectsAllPanels() {
|
||||||
|
@ -38,7 +38,6 @@ jest.mock('app/core/services/context_srv', () => ({
|
|||||||
user: { orgId: 1, orgName: 'TestOrg' },
|
user: { orgId: 1, orgName: 'TestOrg' },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
jest.mock('app/features/dashboard/services/ChangeTracker');
|
|
||||||
|
|
||||||
variableAdapters.register(createConstantVariableAdapter());
|
variableAdapters.register(createConstantVariableAdapter());
|
||||||
const mockStore = configureMockStore([thunk]);
|
const mockStore = configureMockStore([thunk]);
|
||||||
|
@ -23,7 +23,6 @@ import { initVariablesTransaction } from '../../variables/state/actions';
|
|||||||
import { emitDashboardViewEvent } from './analyticsProcessor';
|
import { emitDashboardViewEvent } from './analyticsProcessor';
|
||||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import { ChangeTracker } from '../services/ChangeTracker';
|
|
||||||
import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner';
|
import { createDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner';
|
||||||
|
|
||||||
export interface InitDashboardArgs {
|
export interface InitDashboardArgs {
|
||||||
@ -174,7 +173,6 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
|
|||||||
// init services
|
// init services
|
||||||
const timeSrv: TimeSrv = getTimeSrv();
|
const timeSrv: TimeSrv = getTimeSrv();
|
||||||
const dashboardSrv: DashboardSrv = getDashboardSrv();
|
const dashboardSrv: DashboardSrv = getDashboardSrv();
|
||||||
const changeTracker = new ChangeTracker();
|
|
||||||
|
|
||||||
timeSrv.init(dashboard);
|
timeSrv.init(dashboard);
|
||||||
const runner = createDashboardQueryRunner({ dashboard, timeSrv });
|
const runner = createDashboardQueryRunner({ dashboard, timeSrv });
|
||||||
@ -208,7 +206,6 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
|
|||||||
dashboard.autoFitPanels(window.innerHeight, queryParams.kiosk);
|
dashboard.autoFitPanels(window.innerHeight, queryParams.kiosk);
|
||||||
}
|
}
|
||||||
|
|
||||||
changeTracker.init(dashboard, 2000);
|
|
||||||
keybindingSrv.setupDashboardBindings(dashboard);
|
keybindingSrv.setupDashboardBindings(dashboard);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
dispatch(notifyApp(createErrorNotification('Dashboard init failed', err)));
|
dispatch(notifyApp(createErrorNotification('Dashboard init failed', err)));
|
||||||
|
@ -9,7 +9,7 @@ import { getModalStyles } from '../../styles';
|
|||||||
interface Props {
|
interface Props {
|
||||||
panel: PanelModelWithLibraryPanel;
|
panel: PanelModelWithLibraryPanel;
|
||||||
folderId: number;
|
folderId: number;
|
||||||
isOpen: boolean;
|
isUnsavedPrompt?: boolean;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
onDiscard: () => void;
|
onDiscard: () => void;
|
||||||
@ -18,7 +18,7 @@ interface Props {
|
|||||||
export const SaveLibraryPanelModal: React.FC<Props> = ({
|
export const SaveLibraryPanelModal: React.FC<Props> = ({
|
||||||
panel,
|
panel,
|
||||||
folderId,
|
folderId,
|
||||||
isOpen,
|
isUnsavedPrompt,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onDiscard,
|
onDiscard,
|
||||||
@ -52,11 +52,12 @@ export const SaveLibraryPanelModal: React.FC<Props> = ({
|
|||||||
const styles = useStyles(getModalStyles);
|
const styles = useStyles(getModalStyles);
|
||||||
const discardAndClose = useCallback(() => {
|
const discardAndClose = useCallback(() => {
|
||||||
onDiscard();
|
onDiscard();
|
||||||
onDismiss();
|
}, [onDiscard]);
|
||||||
}, [onDiscard, onDismiss]);
|
|
||||||
|
const title = isUnsavedPrompt ? 'Unsaved library panel changes' : 'Save library panel';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Update all panel instances" icon="save" onDismiss={onDismiss} isOpen={isOpen}>
|
<Modal title={title} icon="save" onDismiss={onDismiss} isOpen={true}>
|
||||||
<div>
|
<div>
|
||||||
<p className={styles.textInfo}>
|
<p className={styles.textInfo}>
|
||||||
{'This update will affect '}
|
{'This update will affect '}
|
||||||
@ -95,14 +96,15 @@ export const SaveLibraryPanelModal: React.FC<Props> = ({
|
|||||||
<Button variant="secondary" onClick={onDismiss} fill="outline">
|
<Button variant="secondary" onClick={onDismiss} fill="outline">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={discardAndClose}>
|
{isUnsavedPrompt && (
|
||||||
Discard
|
<Button variant="destructive" onClick={discardAndClose}>
|
||||||
</Button>
|
Discard
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
saveLibraryPanel(panel, folderId).then(() => {
|
saveLibraryPanel(panel, folderId).then(() => {
|
||||||
onConfirm();
|
onConfirm();
|
||||||
onDismiss();
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
Loading…
Reference in New Issue
Block a user