How to correctly do a form with relationships and server side validations?


#1

This question is also on stackoverflow.

In an ember app, using JSON Api adapter, I have those models :

# subscription.js
import DS from 'ember-data';

export default DS.Model.extend({
  userId: DS.attr(),
  user: DS.belongsTo('user'),
  courseId: DS.attr(),
  course: DS.belongsTo('course')
});

#contact.js
import DS from 'ember-data';

export default DS.Model.extend({
  fullname: DS.attr(),
  phone: DS.attr(),
  email: DS.attr(),
  user: DS.belongsTo('user'),
});

#user.js
import DS from 'ember-data';

export default DS.Model.extend({
  email: DS.attr(),
  subscriptions: DS.hasMany('subscription'),
  course: DS.hasMany('course'),
  contacts: DS.hasMany('contact')
});

Using ember-rapid-form, I have this template :

{{#em-form model=model}}
  {{em-input model=model.user label="Email" property="email" canShowErrors=true}}
  {{em-select label="Course" property="course" content=courses canShowErrors=true prompt=" " propertyIsModel=true optionLabelPath="name"}}
  {{#each model.user.contacts as |contact|}}
    <div class='row'>
      <div class='col-md-4'>
        {{em-input model=contact label="Name" property="fullname" canShowErrors=true}}
      </div>
      <div class='col-md-4'>
        {{em-input model=contact label="Email" property="email" canShowErrors=true}}
      </div>
      <div class='col-md-4'>
        {{em-input model=contact label="Phone" property="phone" canShowErrors=true}}
      </div>
    </div>
  {{/each}}
  <a {{action 'addContact' }}>Add contact</a>
{{/em-form}}

And this route :

import Ember from 'ember';

export default Ember.Route.extend({
  model() {
    var subscription = this.store.createRecord('subscription');
    var user = this.store.createRecord('user');
    subscription.set('user', user);
    return subscription;
  },
  actions: {
    submit: function(token) {
      var subscription = this.controller.model;
      subscription.get('user').then((user) => {
        user.save().then(() => {
          subscription.save().then(() => {
            user.get('contacts').invoke('save');
            this.transitionTo('subscriptions.success');
          }, function() {} );
        })
      })
    },
    addContact: function() {
      var subscription = this.controller.model;
      subscription.get('user').then((user) => {
        var contact = this.store.createRecord('contact');
        user.get('contacts').pushObject(contact);
      })
    },
  }
});

It works but I have problems with my submit method. First, I think it’s ugly, I don’t like nested then. Secondly, if there is a failure with a server side validation, it will not continue and trigger other validations. If some models fail, others can be created on the server side.

I did not found any clean solution on the Internet. The best way can be to pass all data in a single xhr call. I tried without success to pass nested attributes on models.

What’s the best way to do this kind of forms?


#2

It looks like subscription and contacts are unrelated to each other? In that case, there’s no need to nest the save() other than ensuring the initial user save worked. Basically, you could end up with an array of promises where some succeeded and some failed. So It might look something like this…

subscription.get('user').then((user) => {
  user.save().then(()=> {
    let promises = [];
    promises.pushObject(subscription.save());
    promises.pushObjects(user.get('contacts').invoke('save'));

    // RSVP notes copied from: http://emberjs.com/api/classes/RSVP.html#method_allSettled
    RSVP.allSettled(promises).then((array) => {
      // array == [
      //   { state: 'fulfilled', value: 1 },
      //   { state: 'rejected', reason: Error },
      //   { state: 'rejected', reason: Error }
      // ]
      // Note that for the second item, reason.message will be '2', and for the
      // third item, reason.message will be '3'.
     
      if(arrayIsAllSuccess(array)) {
        this.transitionTo('subscroptions.success');
      } else {
        // update the form to display errors
      }
   }, function(error) {
      // Not run. (This block would only be called if allSettled had failed,
      // for instance if passed an incorrect argument type.)
    });
  });
});

#3

Many thanks for your help. I’m stuck at this problem.

Your solution looks nice but I think I will have a problem. I have to wait the subscription for have the id and pass it to the user. Again, I have to wait the user response to save contacts with the user id.

I still think the best solution is to pass all arguments in a single call but I think it’s not available for JSON api yet and others serializers seems to not be designed for that.

I can’t believe than there is no simple solution. Maybe I’m fighting against the ember way but I don’t know what’s the ember way. Do you have an idea?


#4

I wonder if your Ember model doesn’t accurately represent the relationship on the server side. On the server side is it user.subscription_id = subscription.id? I assumed it was the other way around. If it’s Subscription -> hasOne (or hasMany) -> User then I think you’d need to save the subscription first?

However, more generally, the code as you have written can be made into a strict hierarchy of A must succeed, then try B, then try C… by adding the “error” callback as a second parameter to then.

  subscription.get('user').then((user) => {
    user.save().then(() => {
      subscription.save().then(() => {

        // ERROR: there's a different error in the next line, I think:
        // you just got 'user' back from the server, but that saved user has NO contacts (they are all
        // still just on the browser side). You'd have to do something to add the unsaved Contacts to the User 
        // before saving them. 

        RSVP.allSettled(user.get('contacts').invoke('save')).then((array) => {
          if(arrayAllSucceeded(array)) {
           this.transitionTo('subscriptions.success');
          } else {
             // handle errors in one or more contacts
          }
        });
      }, (errs) => {
         // subscription wasn't valid, handle that....
      });
    }, (errs) => {
      // user wasn't valid, handle that....
    })
  })

#5

On the server side, I have a Rails app with exact same models and relations.

If I understand, if subscription.get('user') fails, other validations cannot be triggered. Am I right?

Anyway, maybe I’m accustomed to to the magic of Rails but I find it a little bit difficult to built this form that seems to not be so complex. If I continue in that way, forms will be complexer later.

Maybe my way to think the form is not the best for ember. Do you think there is better flow? Do you find it’s complex?


Example for complex form
#6

I tried to implement the solution you proposed. This is the route :

import arrayAllSucceeded from '../../utils/array_all_succeeded';

export default Ember.Route.extend({
  model() {
    var subscription = this.store.createRecord('subscription');
    var user = this.store.createRecord('user');
    subscription.set('user', user);
    return subscription;
  },
  actions: {
    submit: function(token) {
      var subscription = this.controller.model;
      subscription.get('user').then((user) => {
        user.save().then((user) => {

          subscription.save().then(() => {
            Ember.RSVP.allSettled(user.get('contacts').invoke('save')).then((array) => {
              if(arrayAllSucceeded(array)) {
                this.transitionTo('subscriptions.success');
              }
            });
          }, function() {
            user.get('contacts').invoke('save');
          });
        }, function() {
          subscription.save().then(function() {
          }, function() {
            user.get('contacts').invoke('save');
          });
        })
      });
    },
    addContact: function() {
      var subscription = this.controller.model;
      subscription.get('user').then((user) => {
        var contact = this.store.createRecord('contact');
        user.get('contacts').pushObject(contact);
        contact.set('user', user);
      })
    },
  }
});

And this is the util :

export default function arrayAllSucceeded(array) {
  var success = true;
  array.forEach(function(record){
    if(record.state == 'rejected') {
      success = false;
    }
  });
  return success;
}

It works but I still have problems.

First, records are saved on the server side and second attempt sends an edit request. I can probably do more code to fix that but it seems like a hack.

Secondly, the code is ugly. It’s very not DRY.

I will try to not use Ember-data in this case. It seems to be too custom for it.