Nested object in `DS.attr()` isn't affected by `DS.rollbackAttributes()` - ember.js

I have a model User as follow:
import DS from 'ember-data';
const { attr, Model } = DS;
export default Model.extend({
name: attr("string"),
properties: attr(),
});
User.properties is intended to hold a JSON object.
I am updating the model through a form (using ember-one-way-controls) as follow:
{{one-way-textarea
model.name
update=(action (mut model.name))}}
{{one-way-textarea
model.properties.description
update=(action (mut model.properties.description))}}
I have a button allowing the user to discard the changes by calling a discardChanges action:
actions: {
discardChanges(model) {
model.rollbackAttributes();
},
},
The name attribute changes are correctly discard / reset but the properties attribute is not.
How can I handle this ?

Origin of the problem
Ember Data isn't aware of the changes because it uses === operator to compare the dirtied attribute against the original one. If a change has been spotted, Ember Data stores the dirtied attribute key in the _attributes array. We notice this here. Then, when you call DS.rollbackAttributes(), the model looks at the _attributes to acknowledge the attributes to restore. Here it is.
But the hash aren't the same !
JS is all about value passed by reference. Here is an example from Node interpreter:
> var foo = { description: 'hello' }
undefined
> var bar = foo;
undefined
> bar.description = 'bonjour';
'bonjour'
> bar === foo
true
You are modifying the original object.
Solution
A possible solution is to deep-copy your properties object and manually reset it when calling discardChanges.
You can implement it as a service :
import Ember from 'ember';
const { copy, Service } = Ember;
export default Service.extend({
savedProperties: null,
finalize() {
this.set('savedProperties', null);
},
start(model) {
const properties = copy(model.get('properties'));
this.set("savedProperties", properties);
},
undo(model) {
const savedProperties = this.get('savedProperties');
for (const property in savedProperties) {
if (savedProperties.hasOwnProperty(property)) {
const keyPath = `properties.${property}`;
model.set(keyPath, savedProperties[property]);
}
}
this.set('savedProperties', null);
},
});
You call start when you enter in edition mode.
You call undo when you want to discard the changes.
You call finalize when you successfully saved your record.

Related

Use Mixin property in a Controller

This is a crappy example, but I am merely trying to use a mixin's property in a controller. I did the same thing in a route and could access that property. I've tried every way to reference a property I know... What am I misunderstanding?
// app/mixins/author-data.js
import Ember from 'ember';
export default Ember.Mixin.create({
authorName: 'Example author name',
});
// app/controllers/application.js
import Ember from 'ember';
import AuthorDatas from 'app-name/mixins/author-data';
export default Ember.Controller.extend(AuthorDatas, {
siteTitle: `Site title`,
fromAuthorData: this.get('authorName'),
// returns 💩 - what is the proper syntax?
actions: {
showAuthor() {
var author = this.get('fromAuthorData');
console.log(`Author from controller: ${author}`);
},
},
});
// app/templates/application.hbs
{{fromAuthorData}}
This works...
// app/routes/application.js
import Ember from 'ember';
import AuthorDatas from 'app-name/mixins/author-data';
export default Ember.Route.extend(AuthorDatas, {
afterModel() { // arbitrary
var intro = `Author from route:`;
console.log(`${intro} this.authorName`, this.authorName );
console.log(`${intro} this.get('author-name')`, this.get('authorName') );
},
});
(I would have made an ember-twiddle - but I wasn't sure if Mixins would work the same way ~ since they aren't on the list and there is 0 documentation)
The fromAuthorData property on your controller should be defined like this (I think):
fromAuthorData: Ember.computed('authorName', function() {
return this.get('authorName'); // or whatever derived value you need
}
To understand the problem we need to talk about scope, when you extend/create an object you are merely passing in options, your code is no different than:
let options = {
siteTitle: `Site title`,
// `this` is undefined since we are in strict mode
fromAuthorData: this.get('authorName'),
actions: {
showAuthor() {
var author = this.get('fromAuthorData');
console.log(`Author from controller: ${author}`);
},
}
};
export default Ember.Controller.extend(AuthorDatas, options);
Now to access properties that rely on this being the object holding it you will need a function that is run with the object as it's context that returns that value, enter computed properties.
Your code becomes:
// app/controllers/application.js
import Ember from 'ember';
import AuthorDatas from 'app-name/mixins/author-data';
const { computed } = Ember;
export default Ember.Controller.extend(AuthorDatas, {
siteTitle: `Site title`,
// We add `authorName` as the dependent key, should it change `fromAuthorData` will update
fromAuthorData: computed('authorName', function() {
// your author data stuff
let authorName = this.get('authorName');
// ...
return authorDetails;
}),
actions: {
showAuthor() {
var author = this.get('fromAuthorData');
console.log(`Author from controller: ${author}`);
},
},
});

Ember : fetch data afterwards

I get some data from my API through model in route.js. This data contains somewhere an id on its own, with no relationships or included stuff to get details. So I have to make another API request to get the object with that id.
I did it with a component (to be able to send the id argument) and it works, but I would like to know if that's the way to go and if so, if I did it right and it cannot be simplified (because it looks complex to me for such a simple task):
I call my component with {{resource-name id=evaluation.manager}}
Component template just contains {{name}}
component.js:
import Ember from 'ember';
export default Ember.Component.extend({
store: Ember.inject.service(),
_getResource(id) {
return this.get('store').findRecord('resource', id);
},
resource: Ember.computed('id', function() {
const id = this.get('id');
const proxy = Ember.ObjectProxy.extend(Ember.PromiseProxyMixin);
return proxy.create({
promise: this._getResource(id)
});
}),
name: Ember.computed('resource.isFulfilled', function() {
if (this.get('resource.isFulfilled')) {
return `${this.get('resource.lastName')} ${this.get('resource.firstName')}`;
}
else {
return "...";
}
}),
didReceiveAttrs() {
const id = this.getAttr('id');
Ember.assert('resource-name must have an "id" attribute!', !Ember.isBlank(id));
}
});

Ember-Cli: prepend a findAll() query with a prefix

In one of my routes I need to findAll() of user's Items, but instead of making the standard /items request it has to go to /my/items.
My current solution involves:
// routes/my/item.js
export default Ember.Route.extend({
model() {
this.store.unloadAll('item');
return Ember.$.getJSON('/my/items').then((payload) => {
this.store.pushPayload(payload);
return this.store.peekAll('item');
});
}
});
But unfortunately it's not ideal since it requires to unloadAll() items before making the request as to ensure that the model only returns freshly fetched records while unloading any cached.
A better solution will probably involve creating a custom adapter specifically for this route and overwriting either the findAll() method or urlForFindAll(), but I'm not sure how to properly create and import such custom adapter.
Just for testing I overwrote the default Item adapter and returned findAll('item') in the route's model, and everything worked, the request was prefixed with /my/:
// adapters/item.js
findAll: function(store, type, sinceToken, snapshotRecordArray) {
var query, url;
if (sinceToken) { query = { since: sinceToken }; }
// prefix url with `my` str
url = `my${this.buildURL(type.modelName, null, null, 'findAll')}`;
return this.ajax(url, 'GET', { data: query });
},
// routes/my/item.js
export default Ember.Route.extend({
model() {
return this.store.findAll('item');
}
});
..but that obviously overwrites all findAll() queries for this model, wherein I need to make a custom query only in this route.
This can be solved by using adapterOptions to pass options to the item's adapter using findAll:
1) In the route use adapterOption to pass prefix to the adapter:
return this.store.findAll('item', { adapterOptions: { prefix: 'my' } });
2) In ember-cli overwrite item's default adapter with ember g adapter item.
3) In the adapter overwrite the default findAll to prefix url if such option is passed:
// /app/adapters/item.js
import ApplicationAdapter from './application';
export default ApplicationAdapter.extend({
findAll: function(store, type, sinceToken, snapshotRecordArray) {
var query, url;
if (sinceToken) { query = { since: sinceToken }; }
let prefix = Ember.get(snapshotRecordArray, 'adapterOptions.prefix');
url = `${prefix || ''}${this.buildURL(type.modelName, null, null, 'findAll')}`;
return this.ajax(url, 'GET', { data: query });
},
});
4) Success, this.store.findAll('item', { adapterOptions: { prefix: 'my' } }); will now make a my/items instead of items!

Ember: create a DS.belongsTo *outside* of a Model

I'm wanting to ideally create a DS.belongsTo / BelongsToRelationship in my own class (which is an Ember.Object, but not a DS.Model), or alternatively recreate the functionality to let me hold a reference to a record in my own class. I don't know if it's possible to use a DS.belongsTo outside of a DS.Model, or if so, how to set it up.
Background:
I have an ember-cli app using ember-data + ember-fire + firebase. One of my models has an attribute which is an object holding "type specific" information for the record. I transform this object into my own class based on the type it describes, and some times that type will have references to other records in the database. In these cases I would like to have a DS.belongsTo property set up in my typeSpecific class that I could link to in the same way as linking to a relationship in a regular model.
Alternative:
After a lot of searching and not finding any information on how to do this I made my own class which got me most of the way there. I've just noticed that although I can change the record it references on the client side and have it update, if I change it on the server-side I don't get updates coming through, so it's back to the drawing board.
If anyone is able to tell me how to make this alternative approach work that would serve the purpose too. The idea with this class is that I pass it a model name and id, and it should create the model reference and then keep model and id in sync if either side changes, and also pass through updates if anything on the model it's connected to gets changed just like a regular relationship would.
export default Ember.Object.extend({
id: null,
table: undefined,
model: undefined,
store: undefined,
init: function() {
this._super();
if(this.id && !this.model) {
this.updateModel();
}
else if(this.model && !this.id) {
this.updateId();
}
},
updateModel: function() {
var self = this;
if( this.get('id') ) {
this.store.find(this.get('table'), this.get('id')).then( function(model) {
self.set('model', model);
});
}
else {
self.set('model', undefined);
}
}.observes('id','table'),
updateId: function() {
if(this.get('model')) {
this.set('id', this.get('model.id'));
}
else {
this.set('id', null);
}
}.observes('model'),
});
Edit: code to manipulate the object above:
//Creating a reference:
this.set('target', ModelPointer.create({store:this.get('store'), table:this.get('targetTable'), id:targetId}));
//or:
this.set('target', ModelPointer.create({store:store, table:'myTable'}));
...
this.set('target.id', '42');
I believe that at the moment if I change either the id or model on the client the other will update automatically, eg:
//either:
this.set('target.id', '43');
//or:
this.store.find('myTable','43').then( function(newModel) {
self.set('target.model', newModel);
});
The problem is that if I log into Firebase and change myTable['42'].name='Fred' then the value showing on my web page which is linked to target.model.name doesn't update to 'Fred'. I suspect that if I set target.model.name to 'Fred' on the client side and save it wouldn't update the value on the server either(?)
The cleanest solution I've come up is to not store the id separately (left to the model itself). I've verified that changes I make in Firebase propagate to the displayed entry just fine.
With this solution setting the referenced model can be done with either its id or simply the model instance itself. See the controller code for examples.
Firstly, for reference, some test data for Firebase:
{
"testModels": {
"1": {
"name": "Model one"
},
"2": {
"name": "The second model"
},
"3": {
"name": "Third is the charm"
}
}
}
Thus its model app/models/test-model.js just needs the name in there.
Here is my belongsTo-like proxy class, I put mine under app/utils/proxy-class.js but it should probably be a Mixin:
import Ember from 'ember';
export default Ember.Object.extend({
remote: null, // reference to the remote DS.Model
store: null, // reference to the actual store
storeModel: null, // name of the model in the store
_watchRemote: function() {
var self = this;
if ( typeof self.get('remote') === 'object' ) {
// do nothing, already an object
if ( ! Ember.isPresent( self.get('store') ) ) {
// but set the store from the model
self.set( 'store', self.get('remote.store') );
}
} else if ( typeof self.get('remote') === 'string' ||
typeof self.get('remote') === 'number' ) {
// it's an id, so fetch the model
self._fetchModel( self.get('remote') );
}
}.observes('remote').on('init'), // on change, and during object init
_fetchModel: function( id ) {
var self = this;
self.store.find( self.get('storeModel'), id ).then(
function( model ) {
self.set( 'remote', model );
}, function ( err ) {
console.error( "couldn't read from the store:", err );
});
},
});
I created this controller, and used the browser console to change the model on the fly to test that model changes are picked up:
import Ember from 'ember';
import proxyClass from '../utils/proxy-class';
export default Ember.Controller.extend({
model: {
remoteFromId: null,
remoteFromModel: null,
},
init: function() {
var self = this;
self.set( 'model.remoteFromId',
proxyClass.create({
remote: 1,
store: self.get('store'),
storeModel: 'test-model',
})
);
self.get('store').find( 'test-model', 2 )
.then( function( model ) {
self.set( 'model.remoteFromModel',
proxyClass.create({
remote: model,
storeModel: 'test-model',
// no store provided here: set from the model
})
);
});
}
});
And the template for the controller:
<p>remoteFromId: {{model.remoteFromId.remote.id}}
{{model.remoteFromId.remote.name}}</p>
<p>remoteFromModel: {{model.remoteFromModel.remote.id}}
{{model.remoteFromModel.remote.name}}</p>

Ember clone model for new record

I want to make a clone of a model currently being edited.
I've found a couple of ways that almost work. But neither are perfect.
1) model.get('data.attributes') gets all the attributes except for relationships in camelCase form, generates a new record fine but the relationships are missing of course.
2) model.serialize() generates a JSON object, with all attributes including relationships. But createRecord will not handle it well since the object is not camelCased (attributes with underscores like first_name will not be handled)
After my clone has been created I want to transaction.createRecord(App.Document, myNewModelObject) change/set a couple of attributes and finally commit(). Anyone have some insight in how to do this?
Now we have a add-on to copy models
ember-cli-copyable
With this add on, just add the Copyable mix-in to the target model which is to be copied and use the copy method
Example from the add-on site
import Copyable from 'ember-cli-copyable';
Account = DS.Model.extend( Copyable, {
name: DS.attr('string'),
playlists: DS.hasMany('playList'),
favoriteSong: DS.belongsTo('song')
});
PlayList = DS.Model.extend( Copyable, {
name: DS.attr('string'),
songs: DS.hasMany('song'),
});
//notice how Song does not extend Copyable
Song = DS.Model.extend({
name: DS.attr('string'),
artist: DS.belongsTo('artist'),
});
//now the model can be copied as below
this.get('currentAccount.id') // => 1
this.get('currentAccount.name') // => 'lazybensch'
this.get('currentAccount.playlists.length') // => 5
this.get('currentAccount.playlists.firstObject.id') // => 1
this.get('currentAccount.favoriteSong.id') // => 1
this.get('currentAccount').copy().then(function(copy) {
copy.get('id') // => 2 (differs from currentAccount)
copy.get('name') // => 'lazybensch'
copy.get('playlists.length') // => 5
copy.get('playlists.firstObject.id') // => 6 (differs from currentAccount)
copy.get('favoriteSong.id') // => 1 (the same object as in currentAccount.favoriteSong)
});
How about using toJSON() method instead of serialize() like this
js
transaction.createRecord(App.Document, model.toJSON());
Most simple way I found:
function cloneModel(model) {
const root = model._internalModel.modelName;
const store = model.get('store');
let attrs = model.toJSON();
attrs.id = `clone-${attrs.id}`;
store.pushPayload({
[root]: attrs
});
return store.peekRecord(root, attrs.id);
}
You can use https://github.com/offirgolan/ember-data-copyable. Been using this package for some time now, and it is very much reliable. Some of its features :
Shallow & deep copy an Ember Data model
Shallow & deep copy model relationships
Handles cyclical relationships
Handles custom transforms to create true copies
Overwrite, ignore attributes, and copy objects by reference
Intelligent failure and cleanup
Uses ember-concurrency to allow cancelling a copy task
Here is the simple way to clone your Ember Model with relationships. working fine.
Create a Copyable mixin like,
import Ember from 'ember';
export default Ember.Mixin.create(Ember.Copyable, {
copy(deepClone) {
var model = this, attrs = model.toJSON(), class_type = model.constructor;
var root = Ember.String.decamelize(class_type.toString().split(':')[1]);
if(deepClone) {
this.eachRelationship(function(key, relationship){
if (relationship.kind == 'belongsTo') {
attrs[key] = model.get(key).copy(true);
} else if(relationship.kind == 'hasMany' && Ember.isArray(attrs[key])) {
attrs[key].splice(0);
model.get(key).forEach(function(obj) {
attrs[key].addObject(obj.copy(true));
});
}
});
}
return this.store.createRecord(root, attrs);
}
});
Add the mixin in your model,
Note: If you want to clone your child model then, you need to include the mixin in child model as well
USAGE:
With relationship : YOURMODEL.copy(true)
Without relationship : YOURMODEL.copy()
This will also solve my problem
Account = DS.Model.extend({
name: DS.attr('string'),
playlists: DS.hasMany('playList'),
favoriteSong: DS.belongsTo('song')
});
Duplicate = Ember.Object.extend({});
TemporaryRoute = Ember.Route.extend({
model : function(){
var model = this.store.findAll('account');
var json = model.toJSON();
var duplicateModel = Duplicate.create(json);
this.set('duplicateModel', duplicateModel);
return model;
}
});
Here's an updated answer, it still doesn't handle hasMany relationships.
cloneBelongsTo: function(fromModel, toModel) {
var relationships;
relationships = Em.get(fromModel.constructor, 'relationships');
return relationships.forEach(function(relationshipType) {
var _relType;
_relType = relationships.get(relationshipType);
return _relType.forEach(function(relationship) {
var name, relModel;
relModel = Em.get(fromModel, relationship.name);
if (relationship.kind === 'belongsTo' && relModel !== null) {
name = relationship.name;
return toModel.set(name, fromModel.get(name));
}
});
});
}
And here's how I use it:
// create a JSON representation of the old model
var newModel = oldModel.toJSON();
// set the properties you want to alter
newModel.public = false;
// create a new record
newDocument = store.createRecord('document', newModel);
// call the cloneBelongsTo method after the record is created
cloneBelongsTo(model, newDocument);
// finally save the new model
newDocument.save();