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 @@ -
+
@@ -91,5 +91,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); + } + }; +}