Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to rollback relationship changes in EmberData

I have two models with parent-child relationship: training and exercise:

App.Training = DS.Model.extend({
  exercises: DS.hasMany('App.Exercise')
})

App.Exercise = DS.Model.extend({
  training: DS.belongsTo('App.Training')
})

I want to have a page where a training with all its related exercises is displayed. If the user presses the Edit button, the page becomes editable with the possibility of adding new exercises. I also want to have a Cancel button which discards all the changes made.

Here is my controller:

App.TrainingsShowController = Em.ObjectController.extend({
  editing: false,

  edit: function() {
    this.set('editing', true);
    transaction = this.get('store').transaction();
    transaction.add(this.get('model'));
    this.get('model.exercises').forEach(function(x){
      transaction.add(x);
    });
  },

  cancel: function() {
    this.set('editing', false);
    this.get('model.transaction').rollback();
  },

  save: function() {
    this.set('editing', false);
    this.get('model.transaction').commit();
  },

  addExercise: function() {
    this.get('model.exercises').createRecord({});
  }
})

There are four event handlers in the controller:

  1. edit: The user pressed the Edit button: a transaction is created, the page is put into "Editing" mode.
  2. cancel: The user pressed the Cancel button: transaction is rolled back and back to "Normal" mode.
  3. save: The user pressed the Save button: transaction is commited and back to "Normal" mode.
  4. addExercise: The user pressed the Add exercise button: a new exercise is created (in the same transaction) and added to the trainings.

The rollback functionality works fine except for newly created records: if I push the Edit button, add a new exercise and push the Cancel button, the newly created exercise stays on the page.

What is the best way to get rid of the discarded child record?

UPDATE:

I've created a jsFiddle to reproduce problem, but it worked. Unlike my application here I used DS.FixtureAdapter: http://jsfiddle.net/tothda/LaXLG/13/

Then I've created an other one using DS.RESTAdapter and the problem showed up: http://jsfiddle.net/tothda/qwZc4/5/

In the fiddle try: Edit, Add new and then Rollback.

I figured it out, that in case of the RESTAdapter when I add a new child record to a hasMany relationship, the parent record won't become dirty. Which seems fine, but when I rollback the transaction, the newly created child record stays in the parent's ManyArray.

I still don't know, what's the best way to handle the situation.

like image 566
tothda Avatar asked Feb 04 '13 10:02

tothda


2 Answers

A proper dirty check and rollback for hasMany and belongsTo relationships are sorely lacking in Ember Data. The way it currently behaves is often reported as a bug. This is a big pain point for a lot of developers and there is an ongoing discussion on how to resolve this here:

https://github.com/emberjs/rfcs/pull/21

Until there's a proper solution in place, you can workaround this problem by using the following approach.

First, you'll want to reopen DS.Model and extend it. If you're using globals, you can can just put this (e.g. DS.Model.reopen({})) anywhere, but if you're using Ember CLI, it's best to create an initializer (e.g. ember g initializer model):

import DS from 'ember-data';

export function initialize(/* container, application */) {

    DS.Model.reopen({

        saveOriginalRelations: function() {

            this.originalRelations = {};
            this.constructor.eachRelationship(function(key, relationship) {

                if (relationship.kind === 'belongsTo')
                    this.originalRelations[key] = this.get(key);

                if (relationship.kind === 'hasMany')
                    this.originalRelations[key] = this.get(key).toArray();

            }, this);
        },

        onLoad: function() {

            this.saveOriginalRelations();

        }.on('didLoad', 'didCreate', 'didUpdate'),

        onReloading: function() {

            if (!this.get('isReloading'))
                this.saveOriginalRelations();

        }.observes('isReloading'),    

        rollback: function() {

            this._super();

            if (!this.originalRelations)
                return;

            Ember.keys(this.originalRelations).forEach(function(key) {

                // careful, as Ember.typeOf for ArrayProxy is 'instance'
                if (Ember.isArray(this.get(key))) {
                    this.get(key).setObjects(this.originalRelations[key]);
                    this.get(key).filterBy('isDirty').invoke('rollback');
                    return;
                }

                if (Ember.typeOf(this.get(key)) === 'instance') {
                    this.set(key, this.originalRelations[key]);
                    return;
                }

            }, this);
        },

        isDeepDirty: function() {
            if (this._super('isDirty'))
                return true;

            if (!this.originalRelations)
                return false;

            return Ember.keys(this.originalRelations).any(function(key) {

                if (Ember.isArray(this.get(key))) {
                    if (this.get(key).anyBy('isDirty'))
                        return true;

                    if (this.get(key).get('length') !== this.originalRelations[key].length)
                        return true;

                    var dirty = false;
                    this.get(key).forEach(function(item, index) {
                        if (item.get('id') !== this.originalRelations[key][index].get('id'))
                            dirty = true;
                    }, this);

                    return dirty;
                }

                return this.get(key).get('isDirty') || this.get(key).get('id') !== this.originalRelations[key].get('id');

            }, this);
        }
    });
};

export default {
    name: 'model',
    initialize: initialize
};

The code above essentially stores the original relationships on load or update so that it can later be used for rollback and dirty checking.

model.rollback() should now roll back everything, including hasMany and belongsTo relationships. We still haven't fully addressed the 'isDirty' check though. To do that, we need to override isDirty in the concrete implementation of a model. The reason why we need to do it here and we can't do it generically in DS.Model is because DS.Model doesn't know what property changes to watch for. Here's an example using Ember CLI. The same approach would be used with globals, except that you'd assign this class to something like App.Book:

import DS from 'ember-data';

var Book = DS.Model.extend({

    publisher: DS.belongsTo('publisher'),

    authors: DS.hasMany('author'),

    isDirty: function() {
        return this.isDeepDirty();
    }.property('currentState', 'publisher', 'authors.[]', '[email protected]').readOnly()

});

export default Book;

For the dependent arguments of isDirty, make sure to include all belongsTo relationships and also include 'array.[]' and '[email protected]' for every hasMany relationship. Now isDirty should work as expected.

like image 155
Johnny Oshika Avatar answered Nov 18 '22 23:11

Johnny Oshika


This isn't pretty but you can force it to rollback by manually dirtying the parent record:

parent.send('becomeDirty');
parent.rollback();
parent.get('children.length'); // => 0
like image 1
Nick Ragaz Avatar answered Nov 18 '22 23:11

Nick Ragaz