diff --git a/FEATURES.md b/FEATURES.md index 721b054ba9d..b6fd0768593 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -137,3 +137,24 @@ entry in `config/features.json`. Adds public method for `shouldSerializeHasMany`, used to determine if a `hasMany` relationship can be serialized. + +- `ds-reset-attribute` [#4246](https://github.com/emberjs/data/pull/4246) + + Adds a `resetAttribute` method to models. Similar to `rollbackAttributes`, + but for only a single attribute. + + ```js + // { firstName: 'Tom', lastName: 'Dale' } + let tom = store.peekRecord('person', 1); + + tom.setProperties({ + firstName: 'Yehuda', + lastName: 'Katz' + }); + + tom.resetAttribute('firstName') // { firstName: 'Tom', lastName: 'Katz' } + tom.get('hasDirtyAttributes') // true + + tom.resetAttribute('lastName') // { firstName: 'Tom', lastName: 'Dale' } + tom.get('hasDirtyAttributes') // false + ``` diff --git a/addon/-private/system/model/internal-model.js b/addon/-private/system/model/internal-model.js index b4dec58313d..88928d51158 100644 --- a/addon/-private/system/model/internal-model.js +++ b/addon/-private/system/model/internal-model.js @@ -4,6 +4,7 @@ import RootState from "ember-data/-private/system/model/states"; import Relationships from "ember-data/-private/system/relationships/state/create"; import Snapshot from "ember-data/-private/system/snapshot"; import EmptyObject from "ember-data/-private/system/empty-object"; +import isEnabled from 'ember-data/-private/features'; import { getOwner @@ -434,6 +435,7 @@ InternalModel.prototype = { this.record._notifyProperties(dirtyKeys); }, + /* @method transitionTo @private @@ -849,4 +851,21 @@ InternalModel.prototype = { return reference; } -}; +} + +if (isEnabled('ds-reset-attribute')) { + /* + Returns the latest truth for an attribute - the canonical value, or the + in-flight value. + + @method lastAcknowledgedValue + @private + */ + InternalModel.prototype.lastAcknowledgedValue = function lastAcknowledgedValue(key) { + if (key in this._inFlightAttributes) { + return this._inFlightAttributes[key]; + } else { + return this._data[key]; + } + }; +} diff --git a/addon/-private/system/model/model.js b/addon/-private/system/model/model.js index 64d59c3cd39..2685f5d8960 100644 --- a/addon/-private/system/model/model.js +++ b/addon/-private/system/model/model.js @@ -7,6 +7,7 @@ import { BelongsToMixin } from 'ember-data/-private/system/relationships/belongs import { HasManyMixin } from 'ember-data/-private/system/relationships/has-many'; import { DidDefinePropertyMixin, RelationshipsClassMethodsMixin, RelationshipsInstanceMethodsMixin } from 'ember-data/-private/system/relationships/ext'; import { AttrClassMethodsMixin, AttrInstanceMethodsMixin } from 'ember-data/-private/system/model/attr'; +import isEnabled from 'ember-data/-private/features'; /** @module ember-data @@ -1023,6 +1024,32 @@ if (Ember.setOwner) { }); } +if (isEnabled('ds-reset-attribute')) { + Model.reopen({ + /** + Discards any unsaved changes to the given attribute. + + Example + + ```javascript + record.get('name'); // 'Untitled Document' + record.set('name', 'Doc 1'); + record.get('name'); // 'Doc 1' + record.resetAttribute('name'); + record.get('name'); // 'Untitled Document' + ``` + + @method resetAttribute + */ + resetAttribute(attributeName) { + if (attributeName in this._internalModel._attributes) { + this.set(attributeName, this._internalModel.lastAcknowledgedValue(attributeName)); + } + } + }); +} + + Model.reopenClass(RelationshipsClassMethodsMixin); Model.reopenClass(AttrClassMethodsMixin); diff --git a/config/features.json b/config/features.json index 0e486cb5006..79ca3c55c68 100644 --- a/config/features.json +++ b/config/features.json @@ -7,5 +7,6 @@ "ds-links-in-record-array": null, "ds-overhaul-references": null, "ds-payload-type-hooks": null, - "ds-check-should-serialize-relationships": null + "ds-check-should-serialize-relationships": null, + "ds-reset-attribute": null } diff --git a/tests/unit/model-test.js b/tests/unit/model-test.js index cb25cc457a2..a58452a6537 100644 --- a/tests/unit/model-test.js +++ b/tests/unit/model-test.js @@ -428,6 +428,184 @@ test("changedAttributes() works while the record is being updated", function(ass }); }); +if (isEnabled('ds-reset-attribute')) { + test("resetAttribute() reverts a single attribute to its canonical value", function(assert) { + assert.expect(5); + + run(function() { + store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: 'Peter', + isDrugAddict: true + } + } + }); + + let person = store.peekRecord('person', 1); + + assert.equal(person.get('hasDirtyAttributes'), false, "precond - person record should not be dirty"); + person.setProperties({ + name: 'Piper', + isDrugAddict: false + }); + assert.equal(person.get('hasDirtyAttributes'), true, "record becomes dirty after setting property to a new value"); + person.resetAttribute('isDrugAddict'); + assert.equal(person.get('isDrugAddict'), true, "The specified attribute is rolled back"); + assert.equal(person.get('name'), 'Piper', "Unspecified attributes are not rolled back"); + assert.equal(person.get('hasDirtyAttributes'), true, "record with changed attributes is still dirty"); + }); + }); + + test("calling resetAttribute() on an unmodified property has no effect", function(assert) { + assert.expect(5); + + run(function() { + store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: 'Peter', + isDrugAddict: true + } + } + }); + + let person = store.peekRecord('person', 1); + + assert.equal(person.get('hasDirtyAttributes'), false, "precond - person record should not be dirty"); + person.set('name', 'Piper'); + assert.equal(person.get('hasDirtyAttributes'), true, "record becomes dirty after setting property to a new value"); + person.resetAttribute('isDrugAddict'); + assert.equal(person.get('isDrugAddict'), true, "The specified attribute does not change value"); + assert.equal(person.get('name'), 'Piper', "Unspecified attributes are not rolled back"); + assert.equal(person.get('hasDirtyAttributes'), true, "record with changed attributes is still dirty"); + }); + }); + + test("Rolling back the final value with resetAttribute() causes the record to become clean again", function(assert) { + assert.expect(3); + + run(function() { + store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: 'Peter', + isDrugAddict: true + } + } + }); + + let person = store.peekRecord('person', 1); + + assert.equal(person.get('hasDirtyAttributes'), false, "precond - person record should not be dirty"); + person.set('isDrugAddict', false); + assert.equal(person.get('hasDirtyAttributes'), true, "record becomes dirty after setting property to a new value"); + person.resetAttribute('isDrugAddict'); + assert.equal(person.get('hasDirtyAttributes'), false, "record becomes clean after resetting property to the old value"); + }); + }); + + test("Using resetAttribute on an in-flight record reverts to the latest in-flight value", function(assert) { + assert.expect(4); + + var person, finishSaving; + + // Make sure the save is async + env.adapter.updateRecord = function(store, type, snapshot) { + return new Ember.RSVP.Promise(function(resolve, reject) { + finishSaving = resolve; + }); + }; + + run(function() { + store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: "Tom" + } + } + }); + person = store.peekRecord('person', 1); + person.set('name', "Thomas"); + + person.save(); + }); + + run(function() { + assert.equal(person.get('isSaving'), true); + assert.equal(person.get('name'), "Thomas"); + + person.set('name', "Tomathy"); + assert.equal(person.get('name'), "Tomathy"); + + person.resetAttribute('name'); + assert.equal(person.get('name'), "Thomas"); + + finishSaving(); + }); + }); + + test("Saving an in-flight record updates the in-flight value resetAttribute will use", function(assert) { + assert.expect(7); + + var person, finishSaving; + + // Make sure the save is async + env.adapter.updateRecord = function(store, type, snapshot) { + return new Ember.RSVP.Promise(function(resolve, reject) { + finishSaving = resolve; + }); + }; + + run(function() { + store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: "Tom" + } + } + }); + person = store.peekRecord('person', 1); + person.set('name', "Thomas"); + + person.save(); + }); + + run(function() { + assert.equal(person.get('isSaving'), true); + assert.equal(person.get('name'), "Thomas"); + + person.set('name', "Tomathy"); + assert.equal(person.get('name'), "Tomathy"); + + person.save(); + }); + + run(function() { + assert.equal(person.get('isSaving'), true); + assert.equal(person.get('name'), "Tomathy"); + + person.set('name', "Tomny"); + assert.equal(person.get('name'), "Tomny"); + + person.resetAttribute('name'); + assert.equal(person.get('name'), 'Tomathy'); + + finishSaving(); + }); + }); +} + test("a DS.Model does not require an attribute type", function(assert) { var Tag = DS.Model.extend({ name: DS.attr()