diff --git a/src/Mapper.js b/src/Mapper.js index 5d373f2c..3be42e7f 100644 --- a/src/Mapper.js +++ b/src/Mapper.js @@ -193,6 +193,16 @@ const MAPPER_DEFAULTS = { */ idAttribute: 'id', + /** + * Whether records created from this mapper keep changeHistory on property changes. + * + * @default true + * @name Mapper#keepChangeHistory + * @since 3.0.0 + * @type {boolean} + */ + keepChangeHistory: true, + /** * Whether this Mapper should emit operational events. * diff --git a/src/Record.js b/src/Record.js index 90023262..395d79f4 100644 --- a/src/Record.js +++ b/src/Record.js @@ -134,6 +134,8 @@ function Record (props, opts) { if (id !== undefined) { utils.set(this, mapper.idAttribute, id) } + const keepChangeHistory = opts.keepChangeHistory !== undefined ? opts.keepChangeHistory : (mapper ? mapper.keepChangeHistory : true) + _set('keepChangeHistory', keepChangeHistory) utils.fillIn(this, props) _set('creating', false) _set('noValidate', false) diff --git a/src/Schema.js b/src/Schema.js index 35211097..5256e615 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -743,6 +743,8 @@ const creatingPath = 'creating' const eventIdPath = 'eventId' // boolean - Whether to skip validation for a Record's currently changing property const noValidatePath = 'noValidate' +// boolean - Whether to preserve Change History for a Record +const keepChangeHistoryPath = 'keepChangeHistory' // boolean - Whether to skip change notification for a Record's currently // changing property const silentPath = 'silent' @@ -787,7 +789,6 @@ const makeDescriptor = function (prop, schema, opts) { const _get = this[getter] const _set = this[setter] const _unset = this[unsetter] - // Optionally check that the new value passes validation if (!_get(noValidatePath)) { const errors = schema.validate(value, { path: [prop] }) @@ -802,6 +803,8 @@ const makeDescriptor = function (prop, schema, opts) { // TODO: Make it so tracking can be turned on for all properties instead of // only per-property if (track && !_get(creatingPath)) { + // previous is versioned on database commit + // props are versioned on set() const previous = _get(previousPath) const current = _get(keyPath) let changing = _get(changingPath) @@ -853,12 +856,16 @@ const makeDescriptor = function (prop, schema, opts) { for (i = 0; i < changed.length; i++) { this.emit('change:' + changed[i], this, utils.get(this, changed[i])) } - const changes = this.changes() - const changeRecord = utils.plainCopy(changes) - changeRecord.timestamp = new Date().getTime() - const changeHistory = _get(changeHistoryPath) || [] - _set(changeHistoryPath, changeHistory) - changeHistory.push(changeRecord) + + const changes = utils.diffObjects({ [prop] : value }, { [prop] : current }) + + if (_get(keepChangeHistoryPath)) { + const changeRecord = utils.plainCopy(changes) + changeRecord.timestamp = new Date().getTime() + let changeHistory = _get(changeHistoryPath) + !changeHistory && _set(changeHistoryPath, (changeHistory = [])) + changeHistory.push(changeRecord) + } this.emit('change', this, changes) } _unset(silentPath) diff --git a/test/unit/record/hasChanges.test.js b/test/unit/record/hasChanges.test.js index ace2ad7a..effd8a92 100644 --- a/test/unit/record/hasChanges.test.js +++ b/test/unit/record/hasChanges.test.js @@ -1,4 +1,4 @@ -import { assert, JSData } from '../../_setup' +import { assert, JSData, sinon } from '../../_setup' describe('Record#hasChanges', function () { it('should be an instance method', function () { @@ -13,7 +13,7 @@ describe('Record#hasChanges', function () { post.author = 'Jake' assert(post.hasChanges()) }) - it('should return true if a tracked field is changed', function () { + it('should return true if a tracked field is changed', function (done) { const PostMapper = new JSData.Mapper({ name: 'post', schema: { @@ -26,10 +26,51 @@ describe('Record#hasChanges', function () { } }) const post = PostMapper.createRecord(this.data.p1) + const listener = sinon.stub() + post.on('change', listener) assert(!post.hasChanges()) post.author = 'Jake' assert(post.hasChanges()) post.author = 'John' assert(!post.hasChanges()) + setTimeout(function() { + assert.equal(listener.callCount, 0) + done() + }, 5) + }), + + /* The previous test has a property set and changed back within a single event loop + * So no listener is ever called. + * This test checks that hasChanges() is still false (if the state is set back to the previous) + * even if both changes were registered and a listener was called on each change (twice in total). + */ + + it('is not affected by timing', function(done) { + const PostMapper = new JSData.Mapper({ + name: 'post', + schema: { + properties: { + author: { + type: 'string', + track: true + } + } + } + }) + const post = PostMapper.createRecord(this.data.p1) + const listener = sinon.stub() + post.on('change', listener) + post.author = 'Jake' + assert(post.hasChanges()) + const secondSpec = function() { + assert.equal(listener.callCount, 2) + assert(!post.hasChanges()) + done() + } + setTimeout(function () { + assert.equal(listener.callCount, 1) + post.author = 'John' + setTimeout(secondSpec, 5) + }, 5) }) }) diff --git a/test/unit/record/on.test.js b/test/unit/record/on.test.js new file mode 100644 index 00000000..b6cddf02 --- /dev/null +++ b/test/unit/record/on.test.js @@ -0,0 +1,86 @@ +import { assert, JSData, sinon } from '../../_setup' +const { Record } = JSData + +describe("Record#on('change')", function() { + it('Tracking changes to a single property', function(done) { + const Store = new JSData.DataStore() + const Foo = Store.defineMapper('foo', { + schema: { + properties: { + id: { type: 'number' }, + name: { type: 'string', track: true } + } + }, + }) + const foo = Foo.createRecord() + const listener = sinon.stub() + foo.on('change', listener) + foo.name = 'new foo' + const secondSpec = function() { + foo.name = 'updated foo' + setTimeout(function () { + const [record, changes] = listener.args[1] + assert.equal(foo, record, "on 'change' listener called with record which was modified") + assert.isTrue(listener.calledTwice, "on 'change' listener was called when modifying a property") + assert.equal(Object.keys(changes.added).length, 0) + assert.equal(Object.keys(changes.removed).length, 0) + assert.equal(changes.changed.name, 'updated foo', "Only the property changed was emitted in the changeSet") + done() + }, 5) + } + setTimeout(function () { + const [record, changes] = listener.args[0] + assert.equal(foo, record, "on 'change' listener called with record which was modified") + assert.isTrue(listener.calledOnce, "on 'change' listener was called when modifying a property") + assert.equal(Object.keys(changes.changed).length, 0) + assert.equal(Object.keys(changes.removed).length, 0) + assert.equal(changes.added.name, 'new foo', "Only the property changed was emitted in the changeSet") + secondSpec() + }, 5) + }) + + it('keepChangeHistory: false', function(done) { + const Store = new JSData.DataStore() + + class FooRecord extends Record { + constructor(props, opts) { + super(props, opts) + this._set('keepChangeHistory', false) + this._set('noValidate', true) + } + } + + const Foo = Store.defineMapper('foo', { + recordClass: FooRecord, + schema: { + properties: { + id: { type: 'number' }, + name: { type: 'string', track: true } + } + }, + }) + + const Bar = Store.defineMapper('bar', { + schema: { + properties: { + id: { type: 'number' }, + name: { type: 'string', track: true } + } + }, + }) + + const foo = Foo.createRecord() + const bar = Bar.createRecord() + const listener = sinon.stub() + foo.on('change', listener) + bar.on('change', listener) + foo.name = 'new foo' + bar.name = 'new bar' + setTimeout(function () { + assert.isTrue(listener.calledTwice, "on 'change' listener was called when modifying properties") + assert.deepEqual(foo.changeHistory(), [], "no changeHistory was stored if keepChangeHistory: false is set") + assert.equal(bar.changeHistory().length, 1, "if keepChangeHistory is true by default changeHistory is present") + done() + }, 5) + }) +})