Merge pull request #8148 from BigFunger/appstatus_port

Port of appStatus from 4.x branch
This commit is contained in:
Jim Unger 2016-09-07 14:25:02 -05:00 committed by GitHub
commit 0b472a384e
8 changed files with 428 additions and 20 deletions

View file

@ -1,4 +1,4 @@
<div dashboard-app class="app-container dashboard-container">
<dashboard-app class="app-container dashboard-container">
<kbn-top-nav name="dashboard" config="topNavMenu">
<div class="kibana-nav-info">
<span ng-show="dash.id" class="kibana-nav-info-title">
@ -46,4 +46,4 @@
</div>
<dashboard-grid></dashboard-grid>
</div>
</dashboard-app>

View file

@ -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 + '"');

View file

@ -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();
});
};

View file

@ -1,4 +1,4 @@
<div ng-controller="discover" class="app-container">
<discover-app class="app-container">
<kbn-top-nav name="discover" config="topNavMenu">
<div class="kibana-nav-info">
<span ng-show="opts.savedSearch.id" class="kibana-nav-info-title">
@ -126,4 +126,4 @@
</div>
</div>
</div>
</div>
</discover-app>

View file

@ -1,4 +1,4 @@
<div ng-controller="VisEditor" class="app-container vis-editor vis-type-{{ vis.type.name }}">
<visualize-app class="app-container vis-editor vis-type-{{ vis.type.name }}">
<kbn-top-nav name="visualize" config="topNavMenu">
<div class="vis-editor-info">
@ -91,5 +91,4 @@
</visualize>
</div>
</div>
</div>
</visualize-app>

View file

@ -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();
});
};

View file

@ -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/);
});
});
});
});

View file

@ -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);
}
};
}