Merge pull request #4749 from w33ble/persisted-state

PersistedState Class
This commit is contained in:
Joe Fleming 2015-10-19 14:23:54 -07:00
commit de1332dc3a
4 changed files with 741 additions and 2 deletions

View file

@ -5,10 +5,10 @@ require('ui/promises');
* replace the Promise service with Bluebird so that tests
* can use promises without having to call $rootScope.apply()
*
* var nonDigestPromises = require('testUtils/non_digest_promises');
* var noDigestPromises = require('testUtils/noDigestPromises');
*
* describe('some module that does complex shit with promises', function () {
* beforeEach(nonDigestPromises.activate);
* beforeEach(noDigestPromises.activate);
*
* });
*/

View file

@ -264,5 +264,13 @@ define(function (require) {
};
_.class(errors.InvalidWiggleSelection).inherits(KbnError);
errors.PersistedStateError = function PersistedStateError(msg) {
KbnError.call(this,
msg || 'PersistedState Error',
errors.PersistedStateError);
};
_.class(errors.PersistedStateError).inherits(KbnError);
return errors;
});

View file

@ -0,0 +1,535 @@
var _ = require('lodash');
var sinon = require('auto-release-sinon');
var noDigestPromises = require('testUtils/noDigestPromises');
var ngMock = require('ngMock');
var expect = require('expect.js');
var errors = require('ui/errors');
var PersistedState;
var Events;
describe('Persisted State', function () {
noDigestPromises.activateForSuite();
beforeEach(function () {
ngMock.module('kibana');
ngMock.inject(function (Private) {
PersistedState = Private(require('ui/persisted_state/persisted_state'));
Events = Private(require('ui/events'));
});
});
describe('state creation', function () {
var persistedState;
it('should create an empty state instance', function () {
persistedState = new PersistedState();
expect(persistedState.get()).to.eql({});
});
it('should be an event emitter', function () {
persistedState = new PersistedState();
expect(persistedState).to.be.an(Events);
});
it('should create a state instance with data', function () {
var val = { red: 'blue' };
persistedState = new PersistedState(val);
expect(persistedState.get()).to.eql(val);
// ensure we get a copy of the state, not the actual state object
expect(persistedState.get()).to.not.equal(val);
});
it('should create a copy of the state passed in', function () {
var val = { red: 'blue' };
persistedState = new PersistedState(val);
expect(persistedState.get()).to.eql(val);
expect(persistedState.get()).to.not.equal(val);
});
it('should not throw if creating valid child object', function () {
var run = function () {
var val = { red: 'blue' };
var path = ['test.path'];
var parent = new PersistedState();
new PersistedState(val, path, parent);
};
expect(run).not.to.throwException();
});
it('should throw if given an invalid value', function () {
var run = function () {
var val = 'bananas';
new PersistedState(val);
};
expect(run).to.throwException(function (err) {
expect(err).to.be.a(errors.PersistedStateError);
});
});
it('should not throw if given primitive to child', function () {
var run = function () {
var val = 'bananas';
var path = ['test.path'];
var parent = new PersistedState();
new PersistedState(val, path, parent);
};
expect(run).not.to.throwException();
});
it('should throw if given an invalid parent object', function () {
var run = function () {
var val = { red: 'blue' };
var path = ['test.path'];
var parent = {};
new PersistedState(val, path, parent);
};
expect(run).to.throwException(function (err) {
expect(err).to.be.a(errors.PersistedStateError);
});
});
it('should throw if given a parent without a path', function () {
var run = function () {
var val = { red: 'blue' };
var path;
var parent = new PersistedState();
new PersistedState(val, path, parent);
};
expect(run).to.throwException(function (err) {
expect(err).to.be.a(errors.PersistedStateError);
});
});
});
describe('child state creation', function () {
var childState;
it('should not append the child state to the parent, without parent value', function () {
var childIndex = 'i can haz child';
var persistedState = new PersistedState();
childState = persistedState.createChild(childIndex);
// parent state should not contain the child state
expect(persistedState.get()).to.not.have.property(childIndex);
expect(persistedState.get()).to.eql({});
});
it('should not append the child state to the parent, with parent value', function () {
var childIndex = 'i can haz child';
var persistedStateValue = { original: true };
var persistedState = new PersistedState(persistedStateValue);
childState = persistedState.createChild(childIndex);
// child state should be empty, we didn't give it any default data
expect(childState.get()).to.be(undefined);
// parent state should not contain the child state
expect(persistedState.get()).to.not.have.property(childIndex);
expect(persistedState.get()).to.eql(persistedStateValue);
});
it('should append the child state to the parent, with parent and child values', function () {
var childIndex = 'i can haz child';
var childStateValue = { tacos: 'yes please' };
var persistedStateValue = { original: true };
var persistedState = new PersistedState(persistedStateValue);
childState = persistedState.createChild(childIndex, childStateValue);
// parent state should contain the child and its original state value
var parentState = persistedState.get();
expect(parentState).to.have.property('original', true);
expect(parentState).to.have.property(childIndex);
expect(parentState[childIndex]).to.eql(childStateValue);
});
});
describe('deep child state creation', function () {
it('should delegate get/set calls to parent state', function () {
var children = [{
path: 'first*child',
value: { first: true, second: false }
}, {
path: 'second child',
value: { first: false, second: true }
}];
var persistedStateValue = { original: true };
var persistedState = new PersistedState(persistedStateValue);
// first child is a child of the parent persistedState
children[0].instance = persistedState.createChild(children[0].path, children[0].value);
// second child is a child of the first child
children[1].instance = children[0].instance.createChild(children[1].path, children[1].value);
// second child getter should only return second child value
expect(children[1].instance.get()).to.eql(children[1].value);
// parent should contain original props and first child path, but not the second child path
var parentState = persistedState.get();
_.keys(persistedStateValue).forEach(function (key) {
expect(parentState).to.have.property(key);
});
expect(parentState).to.have.property(children[0].path);
expect(parentState).to.not.have.property(children[1].path);
// second child path should be inside the first child
var firstChildState = children[0].instance.get();
expect(firstChildState).to.have.property(children[1].path);
expect(firstChildState[children[1].path]).to.eql(children[1].value);
// check that the second child is still accessible from the parent instance
var firstChild = persistedState.get(children[0].path);
expect(firstChild).to.have.property(children[1].path);
});
});
describe('colliding child paths and parent state values', function () {
it('should not change the child path value by default', function () {
var childIndex = 'childTest';
var persistedStateValue = {};
persistedStateValue[childIndex] = { overlapping_index: true };
var persistedState = new PersistedState(persistedStateValue);
var state = persistedState.get();
expect(state).to.have.property(childIndex);
expect(state[childIndex]).to.eql(persistedStateValue[childIndex]);
var childState = persistedState.createChild(childIndex);
expect(childState.get()).to.eql(persistedStateValue[childIndex]);
// make sure the parent state is still the same
state = persistedState.get();
expect(state).to.have.property(childIndex);
expect(state[childIndex]).to.eql(persistedStateValue[childIndex]);
});
it('should merge default child state', function () {
var childIndex = 'childTest';
var childStateValue = { child_index: false };
var persistedStateValue = {};
persistedStateValue[childIndex] = { parent_index: true };
var persistedState = new PersistedState(persistedStateValue);
var state = persistedState.get();
expect(state).to.have.property(childIndex);
expect(state[childIndex]).to.eql(persistedStateValue[childIndex]);
// pass in child state value
var childState = persistedState.createChild(childIndex, childStateValue);
// parent's default state overrides child state
var compare = _.merge({}, childStateValue, persistedStateValue[childIndex]);
expect(childState.get()).to.eql(compare);
state = persistedState.get();
expect(state).to.have.property(childIndex);
expect(state[childIndex]).to.eql(compare);
});
});
describe('mutation', function () {
it('should not mutate the internal object', function () {
var persistedStateValue = { hello: 'world' };
var insertedObj = { farewell: 'cruel world' };
var persistedState = new PersistedState(persistedStateValue);
var obj = persistedState.get();
_.assign(obj, insertedObj);
expect(obj).to.have.property('farewell');
expect(persistedState.get()).to.not.have.property('farewell');
});
});
describe('JSON importing and exporting', function () {
var persistedStateValue;
beforeEach(function () {
persistedStateValue = { one: 1, two: 2, 'meaning of life': 42 };
});
describe('exporting state to JSON', function () {
it('should return the full JSON representation', function () {
var persistedState = new PersistedState(persistedStateValue);
var json = persistedState.toJSON();
expect(json).to.eql(persistedStateValue);
});
it('should return the JSON representation of the child state', function () {
var persistedState = new PersistedState(persistedStateValue);
var childState = persistedState.createChild('awesome', { pants: false });
expect(childState.toJSON()).to.eql({ pants: false });
// verify JSON output of the parent state
var parentCompare = _.assign({ awesome: { pants: false }}, persistedStateValue);
expect(persistedState.toJSON()).to.eql(parentCompare);
});
it('should export stringified version of state', function () {
var persistedState = new PersistedState(persistedStateValue);
var childState = persistedState.createChild('awesome', { pants: false });
var data = childState.toString();
expect(JSON.parse(data)).to.eql({ pants: false });
// verify JSON output of the parent state
var parentCompare = _.assign({ awesome: { pants: false }}, persistedStateValue);
expect(JSON.parse(persistedState.toString())).to.eql(parentCompare);
});
});
describe('importing state from JSON string (hydration)', function () {
it('should set the state from JSON string input', function () {
var stateJSON = JSON.stringify(persistedStateValue);
var persistedState = new PersistedState();
expect(persistedState.get()).to.eql({});
persistedState.fromString(stateJSON);
expect(persistedState.get()).to.eql(persistedStateValue);
});
});
});
describe('get state', function () {
it('should perform deep gets with vairous formats', function () {
var obj = {
red: {
green: {
blue: 'yellow'
}
},
orange: [1, 2, false, 4],
purple: {
violet: ''
}
};
var persistedState = new PersistedState(obj);
expect(persistedState.get()).to.eql(obj);
expect(persistedState.get('red')).to.eql({ green: { blue: 'yellow' } });
expect(persistedState.get('red.green')).to.eql({ blue: 'yellow' });
expect(persistedState.get('red[green]')).to.eql({ blue: 'yellow' });
expect(persistedState.get(['red', 'green'])).to.eql({ blue: 'yellow' });
expect(persistedState.get('red.green.blue')).to.eql('yellow');
expect(persistedState.get('red[green].blue')).to.eql('yellow');
expect(persistedState.get('red.green[blue]')).to.eql('yellow');
expect(persistedState.get('red[green][blue]')).to.eql('yellow');
expect(persistedState.get('red.green.blue')).to.eql('yellow');
expect(persistedState.get('orange')).to.eql([1, 2, false, 4]);
expect(persistedState.get('orange[0]')).to.equal(1);
expect(persistedState.get('orange[2]')).to.equal(false);
expect(persistedState.get('purple')).to.eql({ violet: '' });
});
it('should perform deep gets with arrays', function () {
var persistedState = new PersistedState({ hello: { nouns: ['world', 'humans', 'everyone'] } });
expect(persistedState.get()).to.eql({ hello: { nouns: ['world', 'humans', 'everyone'] } });
expect(persistedState.get('hello')).to.eql({ nouns: ['world', 'humans', 'everyone'] });
expect(persistedState.get('hello.nouns')).to.eql(['world', 'humans', 'everyone']);
});
});
describe('set state', function () {
describe('path format support', function () {
it('should create deep objects from dot notation', function () {
var persistedState = new PersistedState();
persistedState.set('one.two.three', 4);
expect(persistedState.get()).to.eql({ one: { two: { three: 4 } } });
});
it('should create deep objects from array notation', function () {
var persistedState = new PersistedState();
persistedState.set('one[two][three]', 4);
expect(persistedState.get()).to.eql({ one: { two: { three: 4 } } });
});
it('should create deep objects from arrays', function () {
var persistedState = new PersistedState();
persistedState.set(['one', 'two', 'three'], 4);
expect(persistedState.get()).to.eql({ one: { two: { three: 4 } } });
});
it('should create deep objects with an existing path', function () {
var persistedState = new PersistedState({}, 'deep.path');
persistedState.set('green[red].blue', 4);
expect(persistedState.get()).to.eql({ green: { red: { blue: 4 } }});
});
});
describe('simple replace operations', function () {
var persistedState;
it('should replace value with string', function () {
persistedState = new PersistedState({ hello: 'world' });
expect(persistedState.get()).to.eql({ hello: 'world' });
persistedState.set('hello', 'fare thee well');
expect(persistedState.get()).to.eql({ hello: 'fare thee well' });
});
it('should replace value with array', function () {
persistedState = new PersistedState({ hello: ['world', 'everyone'] });
expect(persistedState.get()).to.eql({ hello: ['world', 'everyone'] });
persistedState.set('hello', ['people']);
expect(persistedState.get()).to.eql({ hello: ['people'] });
});
it('should replace value with object', function () {
persistedState = new PersistedState({ hello: 'world' });
expect(persistedState.get()).to.eql({ hello: 'world' });
persistedState.set('hello', { message: 'fare thee well' });
expect(persistedState.get()).to.eql({ hello: { message: 'fare thee well' } });
});
it('should replace value with object, removing old properties', function () {
persistedState = new PersistedState({ hello: { message: 'world' } });
expect(persistedState.get()).to.eql({ hello: { message: 'world' } });
persistedState.set('hello', { length: 5 });
expect(persistedState.get()).to.eql({ hello: { length: 5 }});
});
});
describe('deep replace operations', function () {
var persistedState;
it('should append to the object', function () {
persistedState = new PersistedState({ hello: { message: 'world' } });
expect(persistedState.get()).to.eql({ hello: { message: 'world' } });
persistedState.set('hello.length', 5);
expect(persistedState.get()).to.eql({ hello: { message: 'world', length: 5 } });
});
it('should change the value in the array', function () {
persistedState = new PersistedState({ hello: { nouns: ['world', 'humans', 'everyone'] } });
persistedState.set('hello.nouns[1]', 'aliens');
expect(persistedState.get()).to.eql({ hello: { nouns: ['world', 'aliens', 'everyone'] } });
expect(persistedState.get('hello')).to.eql({ nouns: ['world', 'aliens', 'everyone'] });
expect(persistedState.get('hello.nouns')).to.eql(['world', 'aliens', 'everyone']);
});
});
});
describe('internal state tracking', function () {
it('should be an empty object', function () {
var persistedState = new PersistedState();
expect(persistedState._defaultState).to.eql({});
});
it('should store the default state value', function () {
var val = { one: 1, two: 2 };
var persistedState = new PersistedState(val);
expect(persistedState._defaultState).to.eql(val);
});
it('should keep track of changes', function () {
var val = { one: 1, two: 2 };
var persistedState = new PersistedState(val);
persistedState.set('two', 22);
expect(persistedState._defaultState).to.eql(val);
expect(persistedState._changedState).to.eql({ two: 22 });
});
});
describe('events', function () {
var persistedState;
var emitter;
var getByType = function (type, spy) {
spy = spy || emitter;
return spy.getCalls().filter(function (call) {
return call.args[0] === type;
});
};
var watchEmitter = function (state) {
return sinon.spy(state, 'emit');
};
beforeEach(function () {
persistedState = new PersistedState({ checker: { events: 'event tests' } });
emitter = watchEmitter(persistedState);
});
it('should emit set when setting values', function () {
expect(getByType('set')).to.have.length(0);
persistedState.set('checker.time', 'now');
expect(getByType('set')).to.have.length(1);
});
it('should emit change when changing values', function () {
expect(getByType('change')).to.have.length(0);
persistedState.set('checker.time', 'now');
expect(getByType('change')).to.have.length(1);
});
it('should not emit change when values are identical', function () {
expect(getByType('change')).to.have.length(0);
// check both forms of setting the same value
persistedState.set('checker', { events: 'event tests' });
expect(getByType('change')).to.have.length(0);
persistedState.set('checker.events', 'event tests');
expect(getByType('change')).to.have.length(0);
});
it('should emit change when values change', function () {
expect(getByType('change')).to.have.length(0);
persistedState.set('checker.events', 'i changed');
expect(getByType('change')).to.have.length(1);
});
it('should not emit change when createChild has no value', function () {
expect(getByType('change')).to.have.length(0);
persistedState.createChild('checker');
expect(getByType('change')).to.have.length(0);
});
it('should not emit change when createChild is same value', function () {
expect(getByType('change')).to.have.length(0);
persistedState.createChild('checker', { events: 'event tests' });
expect(getByType('change')).to.have.length(0);
persistedState.createChild('checker.events', 'event tests');
expect(getByType('change')).to.have.length(0);
});
it('should emit change when createChild changes existing value', function () {
expect(getByType('change')).to.have.length(0);
persistedState.createChild('checker', { events: 'changed via child' });
expect(getByType('change')).to.have.length(1);
});
it('should emit change when createChild adds new value', function () {
expect(getByType('change')).to.have.length(0);
persistedState.createChild('new.path', { another: 'thing' });
expect(getByType('change')).to.have.length(1);
});
it('should emit on parent and child instances', function (done) {
var child = persistedState.createChild('checker');
expect(getByType('change')).to.have.length(0);
// parent and child should emit, set up listener to test
child.on('change', function () {
// child fired, make sure parent fires as well
expect(getByType('change')).to.have.length(1);
done();
});
child.set('events', 'changed via child set');
});
});
});

View file

@ -0,0 +1,196 @@
define(function (require) {
var _ = require('lodash');
var toPath = require('lodash/internal/toPath');
var errors = require('ui/errors');
return function (Private) {
var Events = Private(require('ui/events'));
var SimpleEmitter = require('ui/utils/SimpleEmitter');
function validateParent(parent, path) {
if (path.length <= 0) {
throw new errors.PersistedStateError('PersistedState child objects must contain a path');
}
if (parent instanceof PersistedState) return;
throw new errors.PersistedStateError('Parent object must be an instance of PersistedState');
}
function validateValue(value) {
var msg = 'State value must be a plain object';
if (!value) return;
if (!_.isPlainObject(value)) throw new errors.PersistedStateError(msg);
}
function parentDelegationMixin(from, to) {
_.forOwn(from.prototype, function (method, methodName) {
to.prototype[methodName] = function () {
return from.prototype[methodName].apply(this._parent || this, arguments);
};
});
}
_.class(PersistedState).inherits(Events);
parentDelegationMixin(SimpleEmitter, PersistedState);
parentDelegationMixin(Events, PersistedState);
function PersistedState(value, path, parent) {
PersistedState.Super.call(this);
this._path = this._setPath(path);
this._parent = parent || false;
if (this._parent) {
validateParent(this._parent, this._path);
} else if (!this._path.length) {
validateValue(value);
}
value = value || this._getDefault();
// copy passed state values and create internal trackers
this.set(value);
this._initialized = true; // used to track state changes
}
PersistedState.prototype.get = function (key, def) {
return _.cloneDeep(this._get(key, def));
};
PersistedState.prototype.set = function (key, value) {
// key must be the value, set the entire state using it
if (_.isUndefined(value) && _.isPlainObject(key)) {
// swap the key and value to write to the state
value = key;
key = undefined;
}
// ensure the value being passed in is never mutated
value = _.cloneDeep(value);
var val = this._set(key, value);
this.emit('set');
return val;
};
PersistedState.prototype.reset = function (key) {
this.set(key, undefined);
};
PersistedState.prototype.clear = function (key) {
this.set(key, null);
};
PersistedState.prototype.createChild = function (path, value) {
return new PersistedState(value, this._getIndex(path), this._parent || this);
};
PersistedState.prototype.getChanges = function () {
return _.cloneDeep(this._changedState);
};
PersistedState.prototype.toJSON = function () {
return this.get();
};
PersistedState.prototype.toString = function () {
return JSON.stringify(this.toJSON());
};
PersistedState.prototype.fromString = function (input) {
return this.set(JSON.parse(input));
};
PersistedState.prototype._getIndex = function (key) {
if (_.isUndefined(key)) return this._path;
return (this._path || []).concat(toPath(key));
};
PersistedState.prototype._getDefault = function () {
var def = (this._hasPath()) ? undefined : {};
return (this._parent ? this.get() : def);
};
PersistedState.prototype._setPath = function (path) {
var isString = _.isString(path);
var isArray = _.isArray(path);
if (!isString && !isArray) return [];
return (isString) ? [this._getIndex(path)] : path;
};
PersistedState.prototype._hasPath = function () {
return this._path.length > 0;
};
PersistedState.prototype._get = function (key, def) {
// delegate to parent instance
if (this._parent) return this._parent._get(this._getIndex(key), key);
// no path and no key, get the whole state
if (!this._hasPath() && _.isUndefined(key)) {
return this._mergedState;
}
return _.get(this._mergedState, this._getIndex(key), def);
};
PersistedState.prototype._set = function (key, value, initialChildState, defaultChildState) {
var self = this;
var stateChanged = false;
var initialState = !this._initialized;
var keyPath = this._getIndex(key);
// if this is the initial state value, save value as the default
if (initialState) {
this._changedState = {};
if (!this._hasPath() && _.isUndefined(key)) this._defaultState = value;
else this._defaultState = _.set({}, keyPath, value);
}
// delegate to parent instance, passing child's default value
if (this._parent) {
return this._parent._set(keyPath, value, initialState, this._defaultState);
}
// everything in here affects only the parent state
if (!initialState) {
// no path and no key, set the whole state
if (!this._hasPath() && _.isUndefined(key)) {
// check for changes and emit an event when found
stateChanged = !_.isEqual(this._changedState, value);
if (!initialChildState) this._changedState = value;
} else {
// check for changes and emit an event when found
stateChanged = !_.isEqual(this.get(keyPath), value);
// arrays merge by index, not the desired behavior - ensure they are replaced
if (!initialChildState) {
if (_.isArray(_.get(this._mergedState, keyPath))) {
_.set(this._mergedState, keyPath, undefined);
}
_.set(this._changedState, keyPath, value);
}
}
}
var targetObj = this._mergedState || _.cloneDeep(this._defaultState);
var sourceObj = _.merge({}, defaultChildState, this._changedState);
var mergeMethod = function (targetValue, sourceValue, mergeKey) {
// If `mergeMethod` is provided it is invoked to produce the merged values of the destination and
// source properties. If `mergeMethod` returns `undefined` merging is handled by the method instead
// handler arguments are (targetValue, sourceValue, key, target, source)
if (!initialState && !initialChildState && _.isEqual(keyPath, self._getIndex(mergeKey))) {
return !_.isUndefined(sourceValue) ? sourceValue : targetValue;
}
};
this._mergedState = _.merge(targetObj, sourceObj, mergeMethod);
if (stateChanged) this.emit('change');
return this;
};
return PersistedState;
};
});