diff --git a/src/core_plugins/kibana/public/dashboard/index.html b/src/core_plugins/kibana/public/dashboard/index.html
index 8bc0f088aecf2..bbcb5812cd063 100644
--- a/src/core_plugins/kibana/public/dashboard/index.html
+++ b/src/core_plugins/kibana/public/dashboard/index.html
@@ -1,4 +1,4 @@
-
+
@@ -46,4 +46,4 @@ Ready to get started?
-
+
diff --git a/src/core_plugins/kibana/public/dashboard/index.js b/src/core_plugins/kibana/public/dashboard/index.js
index 5016556de0a5c..e928e00c3c16b 100644
--- a/src/core_plugins/kibana/public/dashboard/index.js
+++ b/src/core_plugins/kibana/public/dashboard/index.js
@@ -13,6 +13,7 @@ import 'plugins/kibana/dashboard/services/saved_dashboards';
import 'plugins/kibana/dashboard/styles/main.less';
import FilterBarQueryFilterProvider from 'ui/filter_bar/query_filter';
import DocTitleProvider from 'ui/doc_title';
+import stateMonitorFactory from 'ui/state_management/state_monitor_factory';
import uiRoutes from 'ui/routes';
import uiModules from 'ui/modules';
import indexTemplate from 'plugins/kibana/dashboard/index.html';
@@ -54,6 +55,8 @@ uiRoutes
app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, kbnUrl) {
return {
+ restrict: 'E',
+ controllerAs: 'dashboardApp',
controller: function ($scope, $rootScope, $route, $routeParams, $location, Private, getAppState) {
const queryFilter = Private(FilterBarQueryFilterProvider);
@@ -92,8 +95,10 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter,
filters: _.reject(dash.searchSource.getOwn('filter'), matchQueryFilter),
};
+ let stateMonitor;
const $state = $scope.state = new AppState(stateDefaults);
const $uiState = $scope.uiState = $state.makeStateful('uiState');
+ const $appStatus = $scope.appStatus = this.appStatus = {};
$scope.$watchCollection('state.options', function (newVal, oldVal) {
if (!angular.equals(newVal, oldVal)) $state.save();
@@ -143,6 +148,14 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter,
}
initPanelIndices();
+
+ // watch for state changes and update the appStatus.dirty value
+ stateMonitor = stateMonitorFactory.create($state, stateDefaults);
+ stateMonitor.onChange((status) => {
+ $appStatus.dirty = status.dirty;
+ });
+ $scope.$on('$destroy', () => stateMonitor.destroy());
+
$scope.$emit('application.load');
}
@@ -216,6 +229,7 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter,
dash.save()
.then(function (id) {
+ stateMonitor.setInitialState($state.toJSON());
$scope.kbnTopNav.close('save');
if (id) {
notify.info('Saved Dashboard as "' + dash.title + '"');
diff --git a/src/core_plugins/kibana/public/discover/controllers/discover.js b/src/core_plugins/kibana/public/discover/controllers/discover.js
index 111aa678b127b..f70e0d08fd5d3 100644
--- a/src/core_plugins/kibana/public/discover/controllers/discover.js
+++ b/src/core_plugins/kibana/public/discover/controllers/discover.js
@@ -22,6 +22,7 @@ import PluginsKibanaDiscoverHitSortFnProvider from 'plugins/kibana/discover/_hit
import FilterBarQueryFilterProvider from 'ui/filter_bar/query_filter';
import FilterManagerProvider from 'ui/filter_manager';
import AggTypesBucketsIntervalOptionsProvider from 'ui/agg_types/buckets/_interval_options';
+import stateMonitorFactory from 'ui/state_management/state_monitor_factory';
import uiRoutes from 'ui/routes';
import uiModules from 'ui/modules';
import indexTemplate from 'plugins/kibana/discover/index.html';
@@ -79,7 +80,15 @@ uiRoutes
}
});
-app.controller('discover', function ($scope, config, courier, $route, $window, Notifier,
+app.directive('discoverApp', function () {
+ return {
+ restrict: 'E',
+ controllerAs: 'discoverApp',
+ controller: discoverController
+ };
+});
+
+function discoverController($scope, config, courier, $route, $window, Notifier,
AppState, timefilter, Promise, Private, kbnUrl, highlightTags) {
const Vis = Private(VisProvider);
@@ -136,6 +145,8 @@ app.controller('discover', function ($scope, config, courier, $route, $window, N
docTitle.change(savedSearch.title);
}
+ let stateMonitor;
+ const $appStatus = $scope.appStatus = this.appStatus = {};
const $state = $scope.state = new AppState(getStateDefaults());
$scope.uiState = $state.makeStateful('uiState');
@@ -178,6 +189,12 @@ app.controller('discover', function ($scope, config, courier, $route, $window, N
$scope.failuresShown = showTotal;
};
+ stateMonitor = stateMonitorFactory.create($state, getStateDefaults());
+ stateMonitor.onChange((status) => {
+ $appStatus.dirty = status.dirty;
+ });
+ $scope.$on('$destroy', () => stateMonitor.destroy());
+
$scope.updateDataSource()
.then(function () {
$scope.$listen(timefilter, 'fetch', function () {
@@ -303,6 +320,7 @@ app.controller('discover', function ($scope, config, courier, $route, $window, N
return savedSearch.save()
.then(function (id) {
+ stateMonitor.setInitialState($state.toJSON());
$scope.kbnTopNav.close('save');
if (id) {
@@ -571,4 +589,4 @@ app.controller('discover', function ($scope, config, courier, $route, $window, N
}
init();
-});
+};
diff --git a/src/core_plugins/kibana/public/discover/index.html b/src/core_plugins/kibana/public/discover/index.html
index ba66dabafa157..3a89231617bf3 100644
--- a/src/core_plugins/kibana/public/discover/index.html
+++ b/src/core_plugins/kibana/public/discover/index.html
@@ -1,4 +1,4 @@
-
+
@@ -126,4 +126,4 @@ Searching
-
+
diff --git a/src/core_plugins/kibana/public/visualize/editor/editor.html b/src/core_plugins/kibana/public/visualize/editor/editor.html
index 58b0b08f5e73f..c43967427a8b4 100644
--- a/src/core_plugins/kibana/public/visualize/editor/editor.html
+++ b/src/core_plugins/kibana/public/visualize/editor/editor.html
@@ -1,4 +1,4 @@
-
-
-
+
diff --git a/src/core_plugins/kibana/public/visualize/editor/editor.js b/src/core_plugins/kibana/public/visualize/editor/editor.js
index 8eca1caf8882c..548aebe4684b3 100644
--- a/src/core_plugins/kibana/public/visualize/editor/editor.js
+++ b/src/core_plugins/kibana/public/visualize/editor/editor.js
@@ -12,6 +12,7 @@ import DocTitleProvider from 'ui/doc_title';
import UtilsBrushEventProvider from 'ui/utils/brush_event';
import FilterBarQueryFilterProvider from 'ui/filter_bar/query_filter';
import FilterBarFilterBarClickHandlerProvider from 'ui/filter_bar/filter_bar_click_handler';
+import stateMonitorFactory from 'ui/state_management/state_monitor_factory';
import uiRoutes from 'ui/routes';
import uiModules from 'ui/modules';
import editorTemplate from 'plugins/kibana/visualize/editor/editor.html';
@@ -55,8 +56,15 @@ uiModules
'kibana/notify',
'kibana/courier'
])
-.controller('VisEditor', function ($scope, $route, timefilter, AppState, $location, kbnUrl, $timeout, courier, Private, Promise) {
+.directive('visualizeApp', function () {
+ return {
+ restrict: 'E',
+ controllerAs: 'visualizeApp',
+ controller: VisEditor,
+ };
+});
+function VisEditor($scope, $route, timefilter, AppState, $location, kbnUrl, $timeout, courier, Private, Promise) {
const docTitle = Private(DocTitleProvider);
const brushEvent = Private(UtilsBrushEventProvider);
const queryFilter = Private(FilterBarQueryFilterProvider);
@@ -66,6 +74,9 @@ uiModules
location: 'Visualization Editor'
});
+ let stateMonitor;
+ const $appStatus = this.appStatus = {};
+
const savedVis = $route.current.locals.savedVis;
const vis = savedVis.vis;
@@ -104,16 +115,16 @@ uiModules
docTitle.change(savedVis.title);
}
- let $state = $scope.$state = (function initState() {
- const savedVisState = vis.getState();
- const stateDefaults = {
- uiState: savedVis.uiStateJSON ? JSON.parse(savedVis.uiStateJSON) : {},
- linked: !!savedVis.savedSearchId,
- query: searchSource.getOwn('query') || {query_string: {query: '*'}},
- filters: searchSource.getOwn('filter') || [],
- vis: savedVisState
- };
+ const savedVisState = vis.getState();
+ const stateDefaults = {
+ uiState: savedVis.uiStateJSON ? JSON.parse(savedVis.uiStateJSON) : {},
+ linked: !!savedVis.savedSearchId,
+ query: searchSource.getOwn('query') || {query_string: {query: '*'}},
+ filters: searchSource.getOwn('filter') || [],
+ vis: savedVisState
+ };
+ let $state = $scope.$state = (function initState() {
$state = new AppState(stateDefaults);
if (!angular.equals($state.vis, savedVisState)) {
@@ -138,10 +149,18 @@ uiModules
$scope.editableVis = editableVis;
$scope.state = $state;
$scope.uiState = $state.makeStateful('uiState');
+ $scope.appStatus = $appStatus;
+
vis.setUiState($scope.uiState);
$scope.timefilter = timefilter;
$scope.opts = _.pick($scope, 'doSave', 'savedVis', 'shareData', 'timefilter');
+ stateMonitor = stateMonitorFactory.create($state, stateDefaults);
+ stateMonitor.ignoreProps([ 'vis.listeners' ]).onChange((status) => {
+ $appStatus.dirty = status.dirty;
+ });
+ $scope.$on('$destroy', () => stateMonitor.destroy());
+
editableVis.listeners.click = vis.listeners.click = filterBarClickHandler($state);
editableVis.listeners.brush = vis.listeners.brush = brushEvent;
@@ -248,6 +267,7 @@ uiModules
savedVis.save()
.then(function (id) {
+ stateMonitor.setInitialState($state.toJSON());
$scope.kbnTopNav.close('save');
if (id) {
@@ -316,4 +336,4 @@ uiModules
}
init();
-});
+};
diff --git a/src/ui/public/state_management/__tests__/state_monitor_factory.js b/src/ui/public/state_management/__tests__/state_monitor_factory.js
new file mode 100644
index 0000000000000..94b1873779249
--- /dev/null
+++ b/src/ui/public/state_management/__tests__/state_monitor_factory.js
@@ -0,0 +1,253 @@
+import expect from 'expect.js';
+import sinon from 'sinon';
+import { cloneDeep } from 'lodash';
+import stateMonitor from 'ui/state_management/state_monitor_factory';
+import SimpleEmitter from 'ui/utils/simple_emitter';
+
+describe('stateMonitorFactory', function () {
+ const noop = () => {};
+ const eventTypes = [
+ 'save_with_changes',
+ 'reset_with_changes',
+ 'fetch_with_changes',
+ ];
+
+ let mockState;
+ let stateJSON;
+
+ function setState(mockState, obj, emit = true) {
+ mockState.toJSON = () => cloneDeep(obj);
+ stateJSON = cloneDeep(obj);
+ if (emit) mockState.emit(eventTypes[0]);
+ }
+
+ function createMockState(state = {}) {
+ const mockState = new SimpleEmitter();
+ setState(mockState, state, false);
+ return mockState;
+ }
+
+ beforeEach(() => {
+ mockState = createMockState({});
+ });
+
+ it('should have a create method', function () {
+ expect(stateMonitor).to.have.property('create');
+ expect(stateMonitor.create).to.be.a('function');
+ });
+
+ describe('factory creation', function () {
+ it('should not call onChange with only the state', function () {
+ const monitor = stateMonitor.create(mockState);
+ const changeStub = sinon.stub();
+ monitor.onChange(changeStub);
+ sinon.assert.notCalled(changeStub);
+ });
+
+ it('should not call onChange with matching defaultState', function () {
+ const monitor = stateMonitor.create(mockState, {});
+ const changeStub = sinon.stub();
+ monitor.onChange(changeStub);
+ sinon.assert.notCalled(changeStub);
+ });
+
+ it('should call onChange with differing defaultState', function () {
+ const monitor = stateMonitor.create(mockState, { test: true });
+ const changeStub = sinon.stub();
+ monitor.onChange(changeStub);
+ sinon.assert.calledOnce(changeStub);
+ });
+ });
+
+ describe('instance', function () {
+ let monitor;
+
+ beforeEach(() => {
+ monitor = stateMonitor.create(mockState);
+ });
+
+ describe('onChange', function () {
+ it('should throw if not given a handler function', function () {
+ const fn = () => monitor.onChange('not a function');
+ expect(fn).to.throwException(/must be a function/);
+ });
+
+ eventTypes.forEach((eventType) => {
+ describe(`when ${eventType} is emitted`, function () {
+ let handlerFn;
+
+ beforeEach(() => {
+ handlerFn = sinon.stub();
+ monitor.onChange(handlerFn);
+ sinon.assert.notCalled(handlerFn);
+ });
+
+ it('should get called', function () {
+ mockState.emit(eventType);
+ sinon.assert.calledOnce(handlerFn);
+ });
+
+ it('should be given the state status', function () {
+ mockState.emit(eventType);
+ const args = handlerFn.firstCall.args;
+ expect(args[0]).to.be.an('object');
+ });
+
+ it('should be given the event type', function () {
+ mockState.emit(eventType);
+ const args = handlerFn.firstCall.args;
+ expect(args[1]).to.equal(eventType);
+ });
+
+ it('should be given the changed keys', function () {
+ const keys = ['one', 'two', 'three'];
+ mockState.emit(eventType, keys);
+ const args = handlerFn.firstCall.args;
+ expect(args[2]).to.equal(keys);
+ });
+ });
+ });
+ });
+
+ describe('ignoreProps', function () {
+ it('should not set status to dirty when ignored properties change', function () {
+ let status;
+ const mockState = createMockState({ messages: { world: 'hello', foo: 'bar' } });
+ const monitor = stateMonitor.create(mockState);
+ const changeStub = sinon.stub();
+ monitor.ignoreProps('messages.world');
+ monitor.onChange(changeStub);
+ sinon.assert.notCalled(changeStub);
+
+ // update the ignored state prop
+ setState(mockState, { messages: { world: 'howdy', foo: 'bar' } });
+ sinon.assert.calledOnce(changeStub);
+ status = changeStub.firstCall.args[0];
+ expect(status).to.have.property('clean', true);
+ expect(status).to.have.property('dirty', false);
+
+ // update a prop that is not ignored
+ setState(mockState, { messages: { world: 'howdy', foo: 'baz' } });
+ sinon.assert.calledTwice(changeStub);
+ status = changeStub.secondCall.args[0];
+ expect(status).to.have.property('clean', false);
+ expect(status).to.have.property('dirty', true);
+ });
+ });
+
+ describe('setInitialState', function () {
+ let changeStub;
+
+ beforeEach(() => {
+ changeStub = sinon.stub();
+ monitor.onChange(changeStub);
+ sinon.assert.notCalled(changeStub);
+ });
+
+ it('should throw if no state is provided', function () {
+ const fn = () => monitor.setInitialState();
+ expect(fn).to.throwException(/must be an object/);
+ });
+
+ it('should throw if given the wrong type', function () {
+ const fn = () => monitor.setInitialState([]);
+ expect(fn).to.throwException(/must be an object/);
+ });
+
+ it('should trigger the onChange handler', function () {
+ monitor.setInitialState({ new: 'state' });
+ sinon.assert.calledOnce(changeStub);
+ });
+
+ it('should change the status with differing state', function () {
+ monitor.setInitialState({ new: 'state' });
+ sinon.assert.calledOnce(changeStub);
+
+ const status = changeStub.firstCall.args[0];
+ expect(status).to.have.property('clean', false);
+ expect(status).to.have.property('dirty', true);
+ });
+
+ it('should not trigger the onChange handler without state change', function () {
+ monitor.setInitialState(cloneDeep(mockState.toJSON()));
+ sinon.assert.notCalled(changeStub);
+ });
+ });
+
+ describe('status object', function () {
+ let handlerFn;
+
+ beforeEach(() => {
+ handlerFn = sinon.stub();
+ monitor.onChange(handlerFn);
+ });
+
+ it('should be clean by default', function () {
+ mockState.emit(eventTypes[0]);
+ const status = handlerFn.firstCall.args[0];
+ expect(status).to.have.property('clean', true);
+ expect(status).to.have.property('dirty', false);
+ });
+
+ it('should be dirty when state changes', function () {
+ setState(mockState, { message: 'i am dirty now' });
+ const status = handlerFn.firstCall.args[0];
+ expect(status).to.have.property('clean', false);
+ expect(status).to.have.property('dirty', true);
+ });
+
+ it('should be clean when state is reset', function () {
+ const defaultState = { message: 'i am the original state' };
+ const handlerFn = sinon.stub();
+
+ let status;
+
+ // initial state and monitor setup
+ const mockState = createMockState(defaultState);
+ const monitor = stateMonitor.create(mockState);
+ monitor.onChange(handlerFn);
+ sinon.assert.notCalled(handlerFn);
+
+ // change the state and emit an event
+ setState(mockState, { message: 'i am dirty now' });
+ sinon.assert.calledOnce(handlerFn);
+ status = handlerFn.firstCall.args[0];
+ expect(status).to.have.property('clean', false);
+ expect(status).to.have.property('dirty', true);
+
+ // reset the state and emit an event
+ setState(mockState, defaultState);
+ sinon.assert.calledTwice(handlerFn);
+ status = handlerFn.secondCall.args[0];
+ expect(status).to.have.property('clean', true);
+ expect(status).to.have.property('dirty', false);
+ });
+ });
+
+ describe('destroy', function () {
+ let stateSpy;
+ let cleanMethod;
+
+ beforeEach(() => {
+ stateSpy = sinon.spy(mockState, 'off');
+ sinon.assert.notCalled(stateSpy);
+ });
+
+ it('should remove the listeners', function () {
+ monitor.onChange(noop);
+ monitor.destroy();
+ sinon.assert.callCount(stateSpy, eventTypes.length);
+ eventTypes.forEach((eventType) => {
+ sinon.assert.calledWith(stateSpy, eventType);
+ });
+ });
+
+ it('should stop the instance from being used any more', function () {
+ monitor.onChange(noop);
+ monitor.destroy();
+ const fn = () => monitor.onChange(noop);
+ expect(fn).to.throwException(/has been destroyed/);
+ });
+ });
+ });
+});
diff --git a/src/ui/public/state_management/state_monitor_factory.js b/src/ui/public/state_management/state_monitor_factory.js
new file mode 100644
index 0000000000000..6fe7d89f08585
--- /dev/null
+++ b/src/ui/public/state_management/state_monitor_factory.js
@@ -0,0 +1,104 @@
+import { cloneDeep, isEqual, set, isPlainObject } from 'lodash';
+
+export default {
+ create: (state, customInitialState) => stateMonitor(state, customInitialState)
+};
+
+function stateMonitor(state, customInitialState) {
+ let destroyed = false;
+ let ignoredProps = [];
+ let changeHandlers = [];
+ let initialState;
+
+ setInitialState(customInitialState);
+
+ function setInitialState(customInitialState) {
+ // state.toJSON returns a reference, clone so we can mutate it safely
+ initialState = cloneDeep(customInitialState) || cloneDeep(state.toJSON());
+ }
+
+ function removeIgnoredProps(state) {
+ ignoredProps.forEach(path => {
+ set(state, path, true);
+ });
+ return state;
+ }
+
+ function getStatus() {
+ // state.toJSON returns a reference, clone so we can mutate it safely
+ const currentState = removeIgnoredProps(cloneDeep(state.toJSON()));
+ const isClean = isEqual(currentState, initialState);
+
+ return {
+ clean: isClean,
+ dirty: !isClean,
+ };
+ }
+
+ function dispatchChange(type = null, keys = []) {
+ const status = getStatus();
+ changeHandlers.forEach(changeHandler => {
+ changeHandler(status, type, keys);
+ });
+ }
+
+ function dispatchFetch(keys) {
+ dispatchChange('fetch_with_changes', keys);
+ };
+
+ function dispatchSave(keys) {
+ dispatchChange('save_with_changes', keys);
+ };
+
+ function dispatchReset(keys) {
+ dispatchChange('reset_with_changes', keys);
+ };
+
+ return {
+ setInitialState(customInitialState) {
+ if (!isPlainObject(customInitialState)) throw new TypeError('The default state must be an object');
+
+ // check the current status
+ const previousStatus = getStatus();
+
+ // update the initialState and apply ignoredProps
+ setInitialState(customInitialState);
+ removeIgnoredProps(initialState);
+
+ // fire the change handler if the status has changed
+ if (!isEqual(previousStatus, getStatus())) dispatchChange();
+ },
+
+ ignoreProps(props) {
+ ignoredProps = ignoredProps.concat(props);
+ removeIgnoredProps(initialState);
+ return this;
+ },
+
+ onChange(callback) {
+ if (destroyed) throw new Error('Monitor has been destroyed');
+ if (typeof callback !== 'function') throw new Error('onChange handler must be a function');
+
+ changeHandlers.push(callback);
+
+ // Listen for state events.
+ state.on('fetch_with_changes', dispatchFetch);
+ state.on('save_with_changes', dispatchSave);
+ state.on('reset_with_changes', dispatchReset);
+
+ // if the state is already dirty, fire the change handler immediately
+ const status = getStatus();
+ if (status.dirty) dispatchChange();
+
+ return this;
+ },
+
+ destroy() {
+ destroyed = true;
+ changeHandlers = undefined;
+ state.off('fetch_with_changes', dispatchFetch);
+ state.off('save_with_changes', dispatchSave);
+ state.off('reset_with_changes', dispatchReset);
+ }
+ };
+}