tothda
tothda

Reputation: 136

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.

Upvotes: 8

Views: 4942

Answers (4)

dylanmensaert
dylanmensaert

Reputation: 1749

Late to the party, but here we go:

I created an addon that resolves this issue. Just call rollbackRelationships() and it will rollback all your relationships (belongsTo & hasMany). Look at the README for more options.

https://www.npmjs.com/package/ember-rollback-relationships

Upvotes: 0

Johnny Oshika
Johnny Oshika

Reputation: 57612

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.

Upvotes: 12

Eric D. Johnson
Eric D. Johnson

Reputation: 11747

@tothda and other readers to follow. As of Ember Data : 1.0.0-beta.10+canary.7db210f29a the parent is still not designed to make parentTraining.isDirty() a value of true when a child is rolled back. Ember Data does consider a parent record to be dirty when an attribute is changed, but not when a DS.hasMany array has changes (this allows save() to work, so you can updated any changes to the parent's attributes on the server).

The way around this for the case mentioned, where you want to do a rollback() on a newly created child, is to replace the .rollback() with a .deleteRecord() on the child record you want to discard. Ember Data then automatically knows to remove it from the DS.hasMany array then, and you can pat yourself on the back for a rollback well done!

Upvotes: 0

Nick Ragaz
Nick Ragaz

Reputation: 1352

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

Upvotes: 1

Related Questions