How To: CRUD with Validation in Ember.js Using ember-easyForm, ember-validations, and Boostrap

Step 6. Edit Product

Now it is time to create a form for users to edit the products in the book store. We will use ember-easyForm to do this, as it provides a succinct syntax for form creation. It is also nicely integrated with ember-validations for form validations and validation error messages.

Because we are using the Bootstrap CSS framework, we need to register a custom wrapper for ember-easyForm to get the right CSS classes in our form.

Add to app.js, after the declaration of App.ApplicationAdapter:

// App.ApplicationAdapter = DS.FixtureAdapter.extend({
//   namespace: 'ember-crud'
// });

Ember.EasyForm.Config.registerWrapper('bootstrap', {
  formClass: '',
  fieldErrorClass: 'has-error',
  inputClass: 'form-group',
  errorClass: 'help-block error',
  hintClass: 'help-block',
  labelClass: ''
});

We now create a new template for products/edit using the ember-easyForm helpers. The template will show three buttons to save the changes made, cancel any changes, or delete the product. It will also include spaces under each form field to display any validation errors.

Add to index.html:

<script type="text/x-handlebars" data-template-name="products/edit">
  <h2>Edit Product</h2>
  {{#form-for controller id="form-product" wrapper="bootstrap"}}
    {{#input name}}
      {{label-field name text="Product"}}
      {{input-field name class="form-control" autofocus="true"}}
      {{#if view.showError}}
        {{error-field name}}
      {{/if}}
    {{/input}}
    {{#input author}}
      {{label-field author text="Author"}}
      {{input-field author class="form-control"}}
      {{#if view.showError}}
        {{error-field author}}
      {{/if}}
    {{/input}}
    {{#input description}}
      {{label-field description text="Description"}}
      {{input-field description class="form-control"}}
      {{#if view.showError}}
        {{error-field description}}
      {{/if}}
    {{/input}}
    {{#input price}}
      {{label-field price text="Price"}}
      {{input-field price class="form-control"}}
      {{#if view.showError}}
        {{error-field price}}
      {{/if}}
    {{/input}}
    <button type="submit" class="btn btn-primary save-button" {{action "save" model}}>Save</button>
    <button type="button" class="btn btn-default cancel-button" {{action "cancel" model}}>Cancel</button>
    <button type="button" class="btn btn-danger delete-button" {{action "delete" model}}>Delete</button>
  {{/form-for}}
</script>

Next, we need to define the save, cancel and delete actions. We will define these in App.ProductsRoute since the same actions can be used by our New Product form, which we will implement in the next step. In fact, we have already implemented the delete action in Step 4, so just need to implement save and cancel.

The save action will validate the data by calling the validate() method provided by ember-validations before saving the information to the store. If the data validation fails, then this action will not do anything and ember-validations will display validation errors on the form.

The cancel action will roll back any changes made to the product details and redirect the user back to the product details page.

We also need to implement the edit action so that the “Edit” buttons in both the products/index and products/show templates will redirect the user to the edit form:

Modify App.ProductsRoute in app.js to look like this:

App.ProductsRoute = Ember.Route.extend({
  model: function() {
    return this.store.find('product');
  },

  actions: {
    // Redirect to edit form.
    edit: function(product) {
      this.transitionTo('products.edit', product);
    },

    // Save and transition to /products/:product_id only if validation passes.
    save: function(product) {
      var _this = this;
      product.validate().then(function() {
        product.save();
        _this.transitionTo('products.show', product);
      });
    },

    // Roll back and transition to /products/:product_id.
    cancel: function(product) {
      product.rollback();
      this.transitionTo('products.show', product);
    },

    // Delete specified product.
    delete: function(product) {
      product.destroyRecord();
      this.transitionTo('products');
    }
  }
});

Finally, we need to check whether the user navigates away from the Product Edit form without saving or canceling any changes. Perhaps he clicked on the “back” button in his browser, clicked on another navigation link, or entered a new URL into his browser. In situations like these, the product data will be left in a “dirty” state. We want to catch these situations and roll back any changes to the data. To do this, we will implement a function in the deactivate hook of ProductsEditRoute. The function will check if the data is “dirty” (i.e. changed), and if it is not being saved (as a result of clicking the “Save” button). If both conditions are true, then the user has navigated away and the function will roll back any changes.

Append to app.js:

App.ProductsEditRoute = Ember.Route.extend({
  model: function(params) {
    return this.store.find('product', params.product_id);
  },

  // Roll back if the user transitions away by clicking a link, clicking the
  // browser's back button, or otherwise.
  deactivate: function() {
    var model = this.modelFor('products.edit');
    if (model && model.get('isDirty') && !model.get('isSaving')) {
      model.rollback();
    }
  }
});

Here are the tests for the new functionality we just implemented.

First, let us test that the “Edit” buttons in the /products/index and /products/show routes work.

Add to tests.js under the “Integration: Products Index” module:

test('edit button works', function() {
  expect(3);
  var productID = 2;

  visit('/products').then(function() {
    click('#product' + productID + ' .edit-button').then(function() {
      equal(currentRouteName(), 'products.edit');
      equal(currentPath(), 'products.edit');
      equal(currentURL(), '/products/' + productID + '/edit');
    });
  });
});

Add to tests.js under the “Integration: Products Show” module:

test('edit button works', function() {
  expect(3);
  var productID = 3;

  visit('/products/' + productID).then(function() {
    click('.edit-button').then(function() {
      equal(currentRouteName(), 'products.edit');
      equal(currentPath(), 'products.edit');
      equal(currentURL(), '/products/' + productID + '/edit');
    });
  });
});

Next, we will test the Edit Product form. The first test will ensure that the form renders correctly, with the correct values pre-filled in the form fills. The second test will test the “Save” button. It will check that any changes to the form data are indeed saved and displayed in the product details page. The third test will test the “Cancel” button. It will check that any changes to the data are not saved. The fourth test will test the “Delete” button. It will check that the product has indeed been deleted and is no longer displayed on the products index page. Finally, the fifth test will check that any changes to the form data are rolled back if the user navigates away from the form.

Append to tests.js:

module('Integration: Products Edit', {
  setup: function() {
    App.reset();
    App.resetFixtures();
  }
});

test('product edit renders', function() {
  expect(10);
  var productID = 1;

  visit('/products/' + productID + '/edit').then(function() {
    equal(find('h1').text(), 'Products');
    equal(find('h2').text(), 'Edit Product');
    equal(find('form#form-product').length, 1);
    var product = App.Product.FIXTURES.findBy('id', productID.toString());
    equal(find('div.name input').val(), product.name);
    equal(find('div.author input').val(), product.author);
    equal(find('div.description input').val(), product.description);
    equal(find('div.price input').val(), product.price);
    equal(find('form#form-product button.save-button').length, 1);
    equal(find('form#form-product button.cancel-button').length, 1);
    equal(find('form#form-product button.delete-button').length, 1);
  });
});

test('save button works', function() {
  expect(7);
  var productID = 2;

  visit('/products/' + productID + '/edit').then(function() {
    var name = find('div.name input').val();
    var author = find('div.author input').val();
    var description = find('div.description input').val();
    var price = find('div.price input').val();
    fillIn('div.name input', name + 'test');
    fillIn('div.author input', author + 'test');
    fillIn('div.description input', description + 'test');
    fillIn('div.price input', '1' + price);
    click('#form-product .save-button').then(function() {
      equal(currentRouteName(), 'products.show');
      equal(currentPath(), 'products.show');
      equal(currentURL(), '/products/' + productID);
      equal(find('.name').text(), name + 'test');
      equal(find('.author').text(), author + 'test');
      equal(find('.description').text(), description + 'test');
      equal(find('.price').text(), '1' + price);
    });
  });
});

test('cancel button works', function() {
  expect(7);
  var productID = 3;

  visit('/products/' + productID + '/edit').then(function() {
    var name = find('div.name input').val();
    var author = find('div.author input').val();
    var description = find('div.description input').val();
    var price = find('div.price input').val();
    fillIn('div.name input', name + 'test');
    fillIn('div.author input', author + 'test');
    fillIn('div.description input', description + 'test');
    fillIn('div.price input', '1' + price);
    click('#form-product .cancel-button').then(function() {
      equal(currentRouteName(), 'products.show');
      equal(currentPath(), 'products.show');
      equal(currentURL(), '/products/' + productID);
      equal(find('.name').text(), name);
      equal(find('.author').text(), author);
      equal(find('.description').text(), description);
      equal(find('.price').text(), price);
    });
  });
});

test('delete button works', function() {
  expect(4);
  var productID = 4;

  visit('/products/' + productID + '/edit');
  click('#form-product .delete-button').then(function() {
    equal(currentRouteName(), 'products.index');
    equal(currentPath(), 'products.index');
    equal(currentURL(), '/products');
    equal(find('#product' + productID).length, 0);
  });
});

test('navigating away rolls back changes', function() {
  expect(7);
  var productID = 3;

  visit('/products/' + productID + '/edit').then(function() {
    var name = find('div.name input').val();
    var author = find('div.author input').val();
    var description = find('div.description input').val();
    var price = find('div.price input').val();
    fillIn('div.name input', name + 'test');
    fillIn('div.author input', author + 'test');
    fillIn('div.description input', description + 'test');
    fillIn('div.price input', '1' + price);
    visit('/products/' + productID).then(function() {
      equal(currentRouteName(), 'products.show');
      equal(currentPath(), 'products.show');
      equal(currentURL(), '/products/' + productID);
      equal(find('.name').text(), name);
      equal(find('.author').text(), author);
      equal(find('.description').text(), description);
      equal(find('.price').text(), price);
    });
  });
});

Finally, we need to test that the validations work. If any invalid data is entered into the form, the form should not allow us to save the data.

Append to tests.js:

module('Integration: Products Edit Validations', {
  setup: function() {
    App.reset();
    App.resetFixtures();
  }
});

test('validator catches empty name field', function() {
  expect(3);
  var productID = 1;

  visit('/products/' + productID + '/edit').then(function() {
    fillIn('div.name input', '');
    fillIn('div.price input', '1.00');
    click('#form-product .save-button').then(function() {
      equal(currentRouteName(), 'products.edit');
      equal(currentPath(), 'products.edit');
      equal(currentURL(), '/products/' + productID + '/edit');
      visit('/products'); // To cancel edit.
    });
  });
});

test('validator catches empty price field', function() {
  expect(3);
  var productID = 1;

  visit('/products/' + productID + '/edit').then(function() {
    fillIn('div.name input', 'Title');
    fillIn('div.price input', '');
    click('#form-product .save-button').then(function() {
      equal(currentRouteName(), 'products.edit');
      equal(currentPath(), 'products.edit');
      equal(currentURL(), '/products/' + productID + '/edit');
      visit('/products'); // To cancel edit.
    });
  });
});

test('validator catches non-numeric price field', function() {
  expect(3);
  var productID = 1;

  visit('/products/' + productID + '/edit').then(function() {
    fillIn('div.name input', 'Title');
    fillIn('div.price input', 'a');
    click('#form-product .save-button').then(function() {
      equal(currentRouteName(), 'products.edit');
      equal(currentPath(), 'products.edit');
      equal(currentURL(), '/products/' + productID + '/edit');
      visit('/products'); // To cancel edit.
    });
  });
});

test('validator catches negative price field', function() {
  expect(3);
  var productID = 1;

  visit('/products/' + productID + '/edit').then(function() {
    fillIn('div.name input', 'Title');
    fillIn('div.price input', '-1');
    click('#form-product .save-button').then(function() {
      equal(currentRouteName(), 'products.edit');
      equal(currentPath(), 'products.edit');
      equal(currentURL(), '/products/' + productID + '/edit');
      visit('/products'); // To cancel edit.
    });
  });
});

You can get the code for this step by running:

git checkout step-6
Advertisements

5 thoughts on “How To: CRUD with Validation in Ember.js Using ember-easyForm, ember-validations, and Boostrap

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s