State Object Refactor

Refactored the State object to clean some things up and add some tests.
Also fixed the defaults so they are properly applied.

- Changed State#commit to State#save()
- Added a base class for State
- Refactored AppState to inherit State
- Refactored GlboalState to inherit State
- Added utils/diff_object with tests for creating a diff report and
  applying the changes to the target while ignorning vars with an
  underscore prefix and functions
This commit is contained in:
Chris Cowan 2014-07-31 16:53:38 -07:00
parent b73a20d8ef
commit 07e3cb2948
19 changed files with 510 additions and 95 deletions

View file

@ -94,13 +94,13 @@ define(function (require) {
$scope.filterResults = function () {
updateQueryOnRootSource();
$state.commit();
$state.save();
courier.fetch();
};
$scope.save = function () {
$state.title = dash.id = dash.title;
$state.commit();
$state.save();
dash.panelsJSON = JSON.stringify($state.panels);
dash.save()
@ -117,7 +117,7 @@ define(function (require) {
$scope.$on('ready:vis', function () {
if (pendingVis) pendingVis--;
if (pendingVis === 0) {
$state.commit();
$state.save();
courier.fetch();
}
});
@ -125,7 +125,7 @@ define(function (require) {
// listen for notifications from the grid component that changes have
// been made, rather than watching the panels deeply
$scope.$on('change:vis', function () {
$state.commit();
$state.save();
});
// called by the saved-object-finder when a user clicks a vis

View file

@ -202,7 +202,7 @@ define(function (require) {
$scope.updateDataSource()
.then(setupVisualization)
.then(function () {
$state.commit();
$state.save();
var sort = $state.sort;
var timeField = $scope.searchSource.get('index').timeFieldName;
@ -331,9 +331,7 @@ define(function (require) {
};
$scope.resetQuery = function () {
$state.query = stateDefaults.query;
$state.sort = stateDefaults.sort;
$state.columns = stateDefaults.columns;
$state.reset();
$scope.fetch();
};
@ -489,7 +487,7 @@ define(function (require) {
}
// if this commit results in something besides the columns changing, a fetch will be executed.
$state.commit();
$state.save();
}
// TODO: Move to utility class
@ -554,7 +552,7 @@ define(function (require) {
configs: [{
agg: 'date_histogram',
field: $scope.opts.timefield,
interval: $scope.state.interval,
interval: $state.interval,
min_doc_count: 0,
}]
},
@ -585,4 +583,4 @@ define(function (require) {
init();
});
});
});

View file

@ -127,7 +127,7 @@ define(function (require) {
group: [],
split: [],
}),
_g: rison.encode(globalState)
_g: globalState.toRISON()
});
};
@ -230,4 +230,4 @@ define(function (require) {
}
};
});
});
});

View file

@ -71,7 +71,7 @@ define(function (require) {
$scope.changeTab = function (obj) {
$state.tab = obj.title;
$state.commit();
$state.save();
resetCheckBoxes();
};
@ -82,4 +82,4 @@ define(function (require) {
}
};
});
});
});

View file

@ -107,7 +107,7 @@ define(function (require) {
var writeStateAndFetch = function () {
_.assign($state, vis.getState());
watchForConfigChanges();
$state.commit();
$state.save();
justFetch();
};
@ -256,4 +256,4 @@ define(function (require) {
init();
});
});
});

View file

@ -0,0 +1,84 @@
define(function (require) {
var _ = require('lodash');
var rison = require('utils/rison');
return function ModelProvider() {
function Model(attributes) {
// Set the attributes or default to an empty object
this._listners = {};
_.assign(this, attributes);
}
/**
* Returns the attirbutes for the model
* @returns {object}
*/
Model.prototype.toObject = function () {
// return just the data.
return _.omit(this, function (value, key) {
return key.charAt(0) === '_' || _.isFunction(value);
});
};
/**
* Serialize the model to RISON
* @returns {string}
*/
Model.prototype.toRISON = function () {
return rison.encode(this.toObject());
};
/**
* Serialize the model to JSON
* @returns {object}
*/
Model.prototype.toJSON = function () {
return this.toObject();
};
/**
* Adds a listner
* @param {string} name The name of the event
* @returns {void}
*/
Model.prototype.on = function (name, listner) {
if (!_.isArray(this._listners[name])) {
this._listners[name] = [];
}
this._listners[name].push(listner);
};
/**
* Removes listener... if listner is empty then it removes all the events
* @param {string} name The name of the event
* @param {function} [listner]
* @returns {void}
*/
Model.prototype.off = function (name, listner) {
if (!listner) return delete this._listners[name];
this._listners = _.filter(this._listners[name], function (fn) {
return fn !== listner;
});
};
/**
* Emits and event
* @param {string} name The name of the event
* @param {mixed} [args...] Arguments pass to the listners
* @returns {void}
*/
Model.prototype.emit = function () {
var args = Array.prototype.slice.call(arguments);
var name = args.shift();
if (this._listners[name]) {
_.each(this._listners[name], function (fn) {
fn.apply(null, args);
});
}
};
return Model;
};
});

View file

@ -1,24 +1,16 @@
define(function (require) {
var _ = require('lodash');
var module = require('modules').get('kibana/factories');
require('components/state_management/global_state');
module.factory('AppState', function (globalState, $route, $location, Promise) {
module.factory('AppState', function (Private) {
var State = Private(require('components/state_management/state'));
function AppState(defaults) {
globalState._setApp(this, defaults);
this.onUpdate = function (handler) {
return globalState.onAppUpdate(handler);
};
this.commit = function () {
var diff = globalState.commit();
return diff.app.all;
};
AppState.Super.call(this, '_a', defaults);
}
_.inherits(AppState, State);
return AppState;
});
});
});

View file

@ -6,61 +6,18 @@ define(function (require) {
var module = require('modules').get('kibana/global_state');
module.service('globalState', function (Private, $rootScope, $route, $injector, Promise, PromiseEmitter) {
var globalState = this;
module.service('globalState', function (Private, $rootScope) {
var State = Private(require('components/state_management/state'));
var setupSync = Private(require('components/state_management/_state_sync'));
function GlobalState(defaults) {
GlobalState.Super.call(this, '_g', defaults);
}
_.inherits(GlobalState, State);
// store app related stuff in here
var app = {};
// resolve all of these when a global update is detected coming in from the url
var updateListeners = [];
// resolve all of these when ANY global update is detected coming in from the url
var anyUpdateListeners = [];
globalState._setApp = function (newAppState, defaults) {
app.current = newAppState;
app.defaults = _.cloneDeep(defaults);
app.name = $route.current.$$route.originalPath;
app.listeners = [];
sync.pull();
GlobalState.prototype.writeToUrl = function (url) {
return qs.replaceParamInUrl(url, this._urlParam, this.toRISON());
};
globalState.writeToUrl = function (url) {
return qs.replaceParamInUrl(url, '_g', rison.encode(globalState));
};
// exposes sync.pull and sync.push
var sync = setupSync(globalState, updateListeners, app);
$rootScope.$on('$routeUpdate', function () {
sync.pull();
});
globalState.onUpdate = function (handler) {
return new PromiseEmitter(function (resolve, reject, defer) {
updateListeners.push(defer);
}, handler);
};
globalState.onAppUpdate = function (handler) {
return new PromiseEmitter(function (resolve, reject, defer) {
app.listeners.push(defer);
}, handler);
};
/**
* Commit the globalState as a history item
*/
globalState.commit = function () {
return sync.push(true);
};
// pull in the default globalState
sync.pull();
return new GlobalState();
});
});
});

View file

@ -0,0 +1,84 @@
define(function (require) {
var _ = require('lodash');
var rison = require('utils/rison');
var applyDiff = require('utils/diff_object');
return function StateProvider(Private, $rootScope, $location) {
var Model = Private(require('components/state_management/_base_model'));
function State(urlParam, defaults) {
State.Super.call(this);
this._defaults = defaults || {};
this._urlParam = urlParam || '_s';
// When the URL updates we need to fetch the values from the URL
$rootScope.$on('$routeUpdate', _.bindKey(this, 'fetch'));
// Initialize the State with fetch
this.fetch();
}
_.inherits(State, Model);
/**
* Fetches the state from the url
* @returns {void}
*/
State.prototype.fetch = function () {
var search = $location.search();
var stash = rison.decode(search[this._urlParam] || '()');
_.defaults(stash, this._defaults);
// apply diff to state from stash, this is side effecting so
// it will change state in place.
var diffResults = applyDiff(this, stash);
if (diffResults.keys.length) {
this.emit('fetch_with_changes', diffResults.keys);
}
};
/**
* Saves the state to the url
* @returns {void}
*/
State.prototype.save = function () {
var search = $location.search();
var stash = rison.decode(search[this._urlParam] || '()');
var state = this.toObject();
_.defaults(state, this._defaults);
// apply diff to stash from state, this is side effecting so
// it will change stash in place.
var diffResults = applyDiff(stash, state);
if (diffResults.keys.length) {
this.emit('save_with_changes', diffResults.keys);
}
search[this._urlParam] = this.toRISON();
$location.search(search);
};
/**
* Resets the state to the defaults
*
* @returns {void}
*/
State.prototype.reset = function () {
// apply diff to _attributes from defaults, this is side effecting so
// it will change the state in place.
applyDiff(this, this._defaults);
this.save();
};
/**
* Registers a listner for updates to pulls
* @returns {void}
*/
State.prototype.onUpdate = function (cb) {
this.on('fetch_with_changes', cb);
};
return State;
};
});

View file

@ -112,7 +112,7 @@ define(function (require) {
var writeGlobalStateToLastPaths = function () {
var currentUrl = $location.url();
var _g = rison.encode(globalState);
var _g = globalState.toRISON();
$scope.apps.forEach(function (app) {
var url = lastPathFor(app);
@ -124,8 +124,7 @@ define(function (require) {
var writeTime = function (newVal, oldVal) {
globalState.time = _.clone(timefilter.time);
globalState.commit();
globalState.save();
writeGlobalStateToLastPaths();
};

View file

@ -56,4 +56,4 @@ define(function (require) {
return Private;
});
});
});

View file

@ -3,6 +3,7 @@ define(function (require) {
var moment = require('moment');
var datemath = require('utils/datemath');
var module = require('modules').get('kibana');
require('components/state_management/global_state');
module.service('timefilter', function (Promise, globalState, $rootScope) {
@ -33,8 +34,9 @@ define(function (require) {
};
var castTime = function () {
if (globalState.time && globalState.time.from) self.time.from = convertISO8601(globalState.time.from);
if (globalState.time && globalState.time.to) self.time.to = convertISO8601(globalState.time.to);
var time = globalState.time;
if (time && time.from) self.time.from = convertISO8601(time.from);
if (time && time.to) self.time.to = convertISO8601(time.to);
};
this.enabled = function (state) {
@ -68,4 +70,4 @@ define(function (require) {
});
});
});

View file

@ -0,0 +1,57 @@
define(function (require) {
var _ = require('lodash');
var angular = require('angular');
return function (target, source) {
var diff = {};
/**
* Filter the private vars
* @param {string} key The keys
* @returns {boolean}
*/
var filterPrivateAndMethods = function (obj) {
return function (key) {
if (_.isFunction(obj[key])) return false;
return key.charAt(0) !== '_';
};
};
var targetKeys = _(target)
.keys()
.filter(filterPrivateAndMethods(target))
.value();
var sourceKeys = _(source)
.keys()
.filter(filterPrivateAndMethods(source))
.value();
// Find the keys to be removed
diff.removed = _.difference(targetKeys, sourceKeys);
// Find the keys to be added
diff.added = _.difference(sourceKeys, targetKeys);
// Find the keys that will be changed
diff.changed = _.filter(sourceKeys, function (key) {
return !angular.equals(target[key]);
});
// Make a list of all the keys that are changing
diff.keys = _.union(diff.changed, diff.removed, diff.added);
// Remove all the keys
_.each(diff.removed, function (key) {
delete target[key];
});
// Assign the source to the target
_.assign(target, _.pick(source, sourceKeys));
return diff;
};
});

View file

@ -495,4 +495,4 @@ define(function () {
};
/* jshint ignore:end */
return rison;
});
});

View file

@ -73,7 +73,10 @@
'specs/utils/interval',
'specs/utils/versionmath',
'specs/utils/routes/index',
'specs/courier/search_source/_get_normalized_sort'
'specs/courier/search_source/_get_normalized_sort',
'specs/state_management/_base_model',
'specs/state_management/state',
'specs/utils/diff_object'
], function (kibana, sinon) {
kibana.load(function () {
var xhr = sinon.useFakeXMLHttpRequest();
@ -95,4 +98,4 @@
})();</script>
</head>
<body><div id="mocha"></div></body>
</html>
</html>

View file

@ -326,4 +326,4 @@ define(function (require) {
});
});
});

View file

@ -0,0 +1,72 @@
define(function (require) {
var angular = require('angular');
var mocks = require('angular-mocks');
var _ = require('lodash');
var sinon = require('sinon/sinon');
require('services/private');
// Load kibana
require('index');
describe('State Management', function () {
describe('Model', function () {
var $rootScope;
var Model;
beforeEach(function () {
module('kibana');
inject(function (_$rootScope_, Private) {
$rootScope = _$rootScope_;
Model = Private(require('components/state_management/_base_model'));
});
});
it('should take an inital set of values', function () {
var model = new Model({ message: 'test' });
expect(model).to.have.property('message', 'test');
});
it('should trigger $on events', function (done) {
var model = new Model();
var stub = sinon.stub();
model.on('test', stub);
model.emit('test');
sinon.assert.calledOnce(stub);
done();
});
it('should be extendable ($on/$emit)', function (done) {
function MyModel() {
MyModel.Super.call(this);
}
_.inherits(MyModel, Model);
var model = new MyModel();
var stub = sinon.stub();
model.on('test', stub);
model.emit('test');
sinon.assert.calledOnce(stub);
done();
});
it('should serialize _attributes to RISON', function () {
var model = new Model();
model.message = 'Testing... 1234';
var rison = model.toRISON();
expect(rison).to.equal('(message:\'Testing... 1234\')');
});
it('should serialize _attributes for JSON', function () {
var model = new Model();
model.message = 'Testing... 1234';
var json = JSON.stringify(model);
expect(json).to.equal('{"message":"Testing... 1234"}');
});
});
});
});

View file

@ -0,0 +1,113 @@
define(function (require) {
var angular = require('angular');
var mocks = require('angular-mocks');
var _ = require('lodash');
var sinon = require('sinon/sinon');
require('services/private');
// Load kibana
require('index');
describe('State Management', function () {
describe('State', function () {
var $rootScope, $location, State, Model;
beforeEach(function () {
module('kibana');
inject(function (_$rootScope_, _$location_, Private) {
$location = _$location_;
$rootScope = _$rootScope_;
State = Private(require('components/state_management/state'));
Model = Private(require('components/state_management/_base_model'));
});
});
it('should inherit from Model', function () {
var state = new State();
expect(state).to.be.an(Model);
});
it('should save to $location.search()', function () {
var state = new State('_s', { test: 'foo' });
state.save();
var search = $location.search();
expect(search).to.have.property('_s');
expect(search._s).to.equal('(test:foo)');
});
it('should emit an event if changes are saved', function (done) {
var state = new State();
state.on('save_with_changes', function (keys) {
expect(keys).to.eql(['test']);
done();
});
state.test = 'foo';
state.save();
var search = $location.search();
});
it('should fetch the state from $location.search()', function () {
var state = new State();
$location.search({ _s: '(foo:bar)' });
state.fetch();
expect(state).to.have.property('foo', 'bar');
});
it('should emit an event if changes are fetched', function (done) {
var state = new State();
state.on('fetch_with_changes', function (keys) {
expect(keys).to.eql(['foo']);
done();
});
$location.search({ _s: '(foo:bar)' });
state.fetch();
expect(state).to.have.property('foo', 'bar');
});
it('should fire listeners for #onUpdate() on #fetch()', function (done) {
var state = new State();
state.onUpdate(function (keys) {
expect(keys).to.eql(['foo']);
done();
});
$location.search({ _s: '(foo:bar)' });
state.fetch();
expect(state).to.have.property('foo', 'bar');
$rootScope.$apply();
});
it('should apply defaults to fetches', function () {
var state = new State('_s', { message: 'test' });
$location.search({ _s: '(foo:bar)' });
state.fetch();
expect(state).to.have.property('foo', 'bar');
expect(state).to.have.property('message', 'test');
});
it('should reset the state to the defaults', function () {
var state = new State('_s', { message: ['test'] });
state.reset();
var search = $location.search();
expect(search).to.have.property('_s');
expect(search._s).to.equal('(message:!(test))');
expect(state.message).to.eql(['test']);
});
it('should apply the defaults upon initialization', function () {
var state = new State('_s', { message: 'test' });
expect(state).to.have.property('message', 'test');
});
it('should call fetch when $routeUpdate is fired on $rootScope', function () {
var state = new State();
var stub = sinon.spy(state, 'fetch');
$rootScope.$emit('$routeUpdate', 'test');
sinon.assert.calledOnce(stub);
});
});
});
});

View file

@ -0,0 +1,54 @@
define(function (require) {
var diff = require('utils/diff_object');
var _ = require('lodash');
describe('utils/diff_object', function () {
it('should list the removed keys', function () {
var target = { test: 'foo' };
var source = { foo: 'test' };
var results = diff(target, source);
expect(results).to.have.property('removed');
expect(results.removed).to.eql(['test']);
});
it('should list the changed keys', function () {
var target = { foo: 'bar' };
var source = { foo: 'test' };
var results = diff(target, source);
expect(results).to.have.property('changed');
expect(results.changed).to.eql(['foo']);
});
it('should list the added keys', function () {
var target = { };
var source = { foo: 'test' };
var results = diff(target, source);
expect(results).to.have.property('added');
expect(results.added).to.eql(['foo']);
});
it('should list all the keys that are change or removed', function () {
var target = { foo: 'bar', test: 'foo' };
var source = { foo: 'test' };
var results = diff(target, source);
expect(results).to.have.property('keys');
expect(results.keys).to.eql(['foo', 'test']);
});
it('should ignore functions', function () {
var target = { foo: 'bar', test: 'foo' };
var source = { foo: 'test', fn: _.noop };
diff(target, source);
expect(target).to.not.have.property('fn');
});
it('should ignore underscores', function () {
var target = { foo: 'bar', test: 'foo' };
var source = { foo: 'test', _private: 'foo' };
diff(target, source);
expect(target).to.not.have.property('_private');
});
});
});