Ember: create a DS.belongsTo *outside* of a Model - ember.js

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>

Related

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

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.

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));
}
});

Reloading just one model using RSVP in Ember

To load many data models into a route on the model hook you need to use RSVP so I'm doing this:
export default Ember.Route.extend( {
queryParams: {
color: {
refreshModel: true,
},
},
model( params ) {
return Ember.RSVP.hash( {
cars: this.store.query( "car", params ),
stats: this.store.findRecord( "shop", params.shopId ),
} );
},
actions: {
filterByColor( color ) {
if ( color !== -1 ) {
this.transitionTo( { queryParams: { color } } );
}
else {
this.transitionTo( { queryParams: { color: undefined } } );
}
},
},
} );
The problem comes in that stats is always going to be the same but using queryParams I will change the cars list.
The action (triggered from a button) will transition to the same page changing the queryParams, which will call the model hook (as intended) but it will reload both models, cars and stats, when I'm only interested in refreshing cars.
Is there a way, with or without RSVP, to load both models on entry but only update one of them when queryParams change?
You can use setupcontroller hook in you route file. setupController hook always called when you transistion to route. Whenever you change queryParams, model hook called.
you can pass "stats" model into controller by using setupController hook. like that
Modelhook should be like that
model( params ) {
return Ember.RSVP.hash( {
cars: this.store.query( "car", params ),
stats:params.shopId
} );
},
setupController: function(controller, model) {
controller.set('cars', model.cars );
controller.set('stats',this.store.findRecord( "shop", model.shopId ));
}
after these changes you have to use linkto transition with .id and also use cars stats model in controller or template. you cannot use model.cars or model.stats in controller or template. because we are passing cars and stats model in controller by setupcontroller hook.

Ember return length of a model created today

I am trying to do this: I have a model called 'trip', and inside trip, an attribute called 'createdToday', which returns the date when a trip is created. What I want is to return a list of trips that were made today.
Here is my trip model:
import DS from 'ember-data';
export default DS.Model.extend({
driver: DS.belongsTo('driver', {
async: true,
inverse: 'trip'
}),
..... etc .......
createdAt: DS.attr('string', {
defaultValue() {
return new Date();
}
}),
isBookedToday: function(trip) {
var today = new Date().toDateString();
return (today === trip.get('createdAt').toDateString);
},
getTripsToday: Ember.computed('trip.#each.createdAt', function() {
var tripsToday = this.get('trip');
return tripsToday.filterBy('isBookedToday', true).get('length');
})
});
In my isBookedToday, I'm trying to see if an individual trip's created time is the same as todays time, and in getTripsToday, I am trying to loop through all the trips and filtering by isBookedToday.
And in my .hbs file, I'm saying: {{trips.getTripsToday}}, which won't render anything, so something's wrong.
I guess I am most confused at Ember's #each and exactly how it works.
Thanks for any feedback.
First you have to understand that your Trip Model instances represents a single Trip! Its absolutely not the right place to put a function that gives you a filtered list of trips!
Next isBookedToday is a normal function not a Computed Property. So you can't filterBy on it.
You may want to implement a isBookedToday on your trip, but you definitely have to filter your trips on the same place where you fetch them! Probably in a model() hook or a Computed Property on a component or a controller.
So you could do but don't need to do in your models/trip.js:
...
isBookedToday: Ember.computed('createdAt', {
get() {
let now = new Date();
let created = get(this, 'createdAt');
return now.getFullYear() === created.getFullYear() &&
now.getMonth() === created.getMonth() &&
now.getDate() === created.getDate();
}
})
...
And then in your model hook:
model() {
return this.store.findAll('trip').then(trips => trips.filterBy('isBookedToday'));
}
Or in a Computed Property in a controller or a component:
tripsToday: Ember.computed('trips.#each.isBookedToday', {
return get(this, 'trips').filterBy('isBookedToday');
})
Be careful. This will result in confusing things if you leave the page open overnight! when your date changes the Computed Properties will not recompute automatically!

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();