Skip to content

Instantly share code, notes, and snippets.

@pablobm
Last active June 3, 2022 10:07
Show Gist options
  • Save pablobm/e77a98e5f3c610953a82 to your computer and use it in GitHub Desktop.
Save pablobm/e77a98e5f3c610953a82 to your computer and use it in GitHub Desktop.
A clear convention for a CRUD with standard Ember + Ember Data

CRUD with Ember (+ Data)

Compatible with Ember 1.13.0+ Compatible with Ember Data 1.13.0+

Ember's official documentation describes a number of low-level APIs, but doesn't talk much about how to put them together. As a result, a simple task such as creating a simple CRUD application is not obvious to a newcomer.

To help solving this problem, I decided to figure out and document a clear convention for simple CRUD apps, using Ember and Ember Data with no third-party add-ons.

I hope this will be a useful resource for beginners who, fresh out of reading the Guides, could use more directions on basic conventions, idioms and patterns in use by the Ember community. I also hope to benefit myself personally, when readers point out mistakes (which will be corrected) and bring up differing opinions (which will be considered).

The following sections discuss considerations taken when creating this convention. See the final code files listed separately further down.

This is a work in progress.

General principles

This implementation is heavily influenced by the style of Ruby on Rails CRUDs, with which I am most familiar.

(Incidentally, Ruby on Rails does a great job of communicating its basic CRUD convention. Not only through its documentation, but also with tools such as the scaffolding generator.)

This is what I expect from this convention:

  1. This CRUD assumes that each action will take place in a different page/view/route
  2. Records will be persisted to the server using Ember Data. The adapter/serializer parts are supposed to be working and are not relevant
  3. Validation will happen server-side
  4. The interface will accomodate for the possibility of validation errors

This is not very DRY

This code could use mixins and components to avoid repetition. However I am avoiding this because:

  1. I want this as a simple, readable example with minimum complexity
  2. This example can serve as a starting point for more complex applications where there's no such duplication

If you use this code, you may want to DRY it up as suggested.

The model

This example assumes the model is called line. It's defined as an Ember Data model and it only has one attribute: name, which is a string.

Routes

Following Ruby on Rails's lead, the paths/routes for each CRUD action are the following:

  • /lines - list existing records
  • /lines/new - create a new record
  • /lines/:line_id - show a single record
  • /lines/:line_id/edit - edit and update a single record
  • /lines/:line_id/destroy - delete a record, but confirm first

Unlike the usual basic Rails paths though, the id segments include the name of the model. This is, they read :line_id instead of just :id. This is because:

  • The default implementation of Route#model (the model() hook in routes) expects this name (see "The model() hook" below).
  • It is necessary anyway when working with nested models. Eg: /receipt/:receipt_id/lines/:line_id

The index route

Ember automatically assumes that there is an index route when we nest routes. Therefore,we don't need to declare a route lines.index like the following:

  this.route('lines', function() {
    this.route('index', {path: '/'});
    // ...
  });

Ember provides it without us specifying anything. This also means any link or transition to the route lines will take us to lines.index. For example, the following transitions are equivalent:

this.transitionTo('lines');
this.transitionTo('lines.index');

The model() hook (or its absence)

Route#model() has a default implementation that may work for you already. If the last segment parameter of your path (eg: :line_id in /receipts/:receipt_id/lines/:line_id) matches the format :{NAME}_id, Ember will assume that {NAME} is the name of your model, and will fetch it from the store with this.store.find('{NAME}', params.{NAME}_id).

Therefore, if you follow the convention of having a segment :line_id, you may not need to implement the model() hook at all.

The show route can be missing too

Following on the previous point, you may not need to provide an explicit show route:

  1. Ember will provide implicit routes for any routes mentioned in app/router.js
  2. An implicit route is equivalent to an empty route. In other words: a route that extends Ember.Route but doesn't add anything to it
  3. Ember.Route has an implementation of the model() hook, which will be used in an empty route

Remember that you will still need to provide a template.

find-er methods

If you still need to grab a record from the store, use Store#findRecord, not Store#find.

Before Ember Data 1.13, you would retrieve records with the methods Store#find, Store#all, Store#getById and others. These have been deprecated and removed.

These are the methods you should be using now, as seen on the Ember Data 1.13 release announcement:

Async from server/store Sync from store Query server
Single Record findRecord(type,id) peekRecord(type, id) queryRecord(type, {query})
All Records findAll(type) peekAll(type) query(type, {query})

I told a little lie though: Store#find hasn't been removed, and it won't even trigger a deprecation alert. You could use it and would not notice. However, it's marked private on Ember Data's source code, and usage by your code is discouraged. It has been kept around for compatibility reasons, because Ember (which is separate from Ember Data) uses it for the default implementation of the Route#model() hook. Check out the source code for more information.

Accessing the current model

There seem to be two accepted ways of obtaining the current model from a route (eg: from an action handler):

// These two should be equivalent
this.controller.get('model');
this.modelFor(this.routeName);

I personally prefer the first of the two, for aesthetic reasons more than anything. Some people are concerned that "controllers will be removed from Ember in the future", but the core team reassures us that this is not going to happen any time soon, and the replacement code will be very similar. If you want to future-proof your application, there are better places to spend your time than in avoiding controllers.

In the wild, you may see other techniques that achieve the same purpose:

  • Reading from this.currentModel. A private, undocumented API. Don't expect it to be there tomorrow

  • Implementing setupController(ctrl, model) and capturing the model there. This is a bit convoluted, and involves two subtle risks. The first one is that you may forget calling this._super(ctrl, model) and wonder why your app doesn't work. The second is that you may then do this.model = model and overwrite the model() function, breaking the route in later invocations. There's a similar risk if you overwrite this.currentModel, but more exciting. Exciting because initially it will work but, since it's a private API, who knows how it could break in the future.

Trigger actions on form submission

On forms, it's tempting to associate the save action (or similar) with clicking a button. In fact, that's what the guides teach you to do:

<button {{action 'save' model}}>Save</button>

But then we cannot press Enter on text fields to save. Remember that, on the web, data is submitted on a submit event. Use this to get forms to work as they should:

<form {{action 'save' model on='submit'}}>
  <!-- ...form fields... -->
  <button>Save</button>
</form>

This should get you some extra usability and accessibility brownie points, if only because that's the way forms are intended to work.

Capture DOM events with "classic" actions

DOM events, such as click or submit (among others) should be captured using "classic" actions, as opposed to "closure" actions. Example follows:

<form {{action 'confirm' model on='submit'}}>

This is because classic actions stop the propagation and the default behaviour of these events, whereas closure actions don't. For example, say that we capture the submit event with a closure action as follows:

<form onsubmit={{action 'confirm' model}}>

This will not stop the submit event in any way. As a result, the browser will attempt to submit the form, causing a page refresh and losing the state of the app. Avoid.

Do use closure actions with Ember components though. They are great.

willTransition and didTransition

When detecting a move away from the route, prefer willTransition over deactivate. This is because the latter doesn't fire when only the model changes.

For the same reason, to detect a successful landing on a route, prefer didTransition over activate.

This may not sound relevant, but consider the following example. Say you extend the edit route to show a list of existing records (like the index route). As you edit the record, you'll see it updating on the list, which is pretty cool. However, if you:

  1. Edit a record
  2. Use the list to navigate away to another record
  3. Click cancel to return to index

The original record will remain edited (not rolled back), but won't have been persisted. After reloading the page, the change will disappear.

Always call _super on hooks

Quoth @rwjblue (https://youtu.be/7kU5lLnEtJs?t=4m24s):

You should always call _super whenever you implement any framework hook. Period. End of story. Even if you know it doesn’t need to be done, you should do it.

Therefore, always call this._super(...arguments) on Ember hooks. This includes willTransition.

Discarding changes to a record

To discard changes to a record, use Model#rollbackAttributes:

  • When updating an existing record, it will discard any unsaved changes.
  • When creating a new record, it will drop it from the store.

Since it works in both cases, it opens up new opportunities for reusing code. For example in the form of mixins.

There's something slightly annoying with the name rollbackAttributes, and it is that it conveys the meaning "discard unsaved changes", but not so much "drop unsaved records". Still, this is the accepted way of doing things, and is documented on the guides and the API docs.

For the specific case of dropping unsaved records, there are other alternatives. You can check if the record has been saved, and delete it if not. It would be something like this:

if (record.get('isNew')) {
  // Delete the record here
}

Now, to actually delete the record, we have these options:

  • Model#deleteRecord: between Ember Data v2.0.0.beta1 and v2.5.0, a bug made this not work correctly when the record is unsaved but has errors. Ie: the user fills out the form, the app tries to save, server-side validation returns errors, the model has its Model#errors populated.

  • Model#destroyRecord: same as deleteRecord, but also tries to persist the deletion of this actually-not-persisted record. For this, it makes a request to DELETE /{model-name}/{id} but, since there's no id yet, it ends up being DELETE /{model-name}.

  • Store#unloadRecord: this actually works well

Still, Model#rollbackAttributes is the best way to do it. It is usable in two different situations, and it saves you checking for record.get('isNew').

Alternative: use buffer object as models

Using Store#createRecord on the new route has two gotchas:

  1. As seen above, we have to remember to undo the changes if we cancel (ie: transition away without saving)

  2. If we are showing a list of records on the same page where the creation form is, we'll see the "ghost", new record appear unless we explicitly filter it out

Some people then prefer using a "buffer" object that holds the values for the new object, without adding it to the store until the user decides to save. This can be done with a plain JS object:

import Ember from 'ember';

export default Ember.Route.extend({
  model() {
    return {};
  },

  actions: {
    save(record) {
      const model = this.store.createRecord('line', record);
      model.save()
        .then(() => this.transitionTo('lines'));
    },
  },
});

Note that this tackles both issues: we don't need to worry about handling the willTransition event, and the new record won't be listed in the store until it's finally saved.

However, this doesn't entirely work:

  1. Validation errors will not be added to the buffer object, and therefore won't be rendered on the form.

  2. On error, the record will have been added to the store anyway, and it will appear on the list after transitioning away.

Validation errors on the object as a whole

Remember that validation errors can be associated to a record as a whole, as opposed to individual fields. Maybe you were trying to buy an item that run out, or there's an invalid interaction between several fields that cannot be blamed on a specific one. This would be communicated to the user with a "general" error message instead of flagging a section of a form.

As far as I know, there's no common convention for addressing this, and different backend frameworks may (or may not) offer support for it in different ways. For example, Ruby on Rails used to address this case with an explicit API, but this has since been dropped. Nowadays, you would be expected to list these errors as if they were part of a field that is not really on the form.

Your code may want to check on this pseudo-attribute too. In Ruby on Rails, this is normally called base, so in this case you'd want to make sure to display any errors on model.errors.base.

See also...

Resources I have found or been pointed to. I'm currently going through them:

import Ember from 'ember';
import config from './config/environment';
const Router = Ember.Router.extend({
location: config.locationType
});
Router.map(function() {
this.route('lines', function() {
this.route('new');
this.route('show', {path: ':line_id'});
this.route('edit', {path: ':line_id/edit'});
this.route('destroy', {path: ':line_id/destroy'});
});
});
export default Router;
import Ember from 'ember';
export default Ember.Route.extend({
actions: {
confirm(record) {
record.destroyRecord()
.then(() => this.transitionTo('lines'));
},
}
});
import Ember from 'ember';
export default Ember.Route.extend({
actions: {
save(record) {
record.save()
.then(() => this.transitionTo('lines'));
},
willTransition() {
this._super(...arguments);
const record = this.controller.get('model');
record.rollbackAttributes();
},
},
});
import Ember from 'ember';
export default Ember.Route.extend({
model() {
return this.store.findAll('line');
}
});
import Ember from 'ember';
export default Ember.Route.extend({
model() {
return this.store.createRecord('line');
},
actions: {
save(record) {
record.save()
.then(() => this.transitionTo('lines'));
},
willTransition() {
this._super(...arguments);
const record = this.controller.get('model');
record.rollbackAttributes();
},
},
});
<p>Are you sure?</p>
<form {{action 'confirm' model on='submit'}}>
<p><button>Delete <strong>{{model.name}}</strong></button> or {{#link-to 'lines'}}cancel{{/link-to}}</p>
</form>
<form {{action 'save' model on='submit'}}>
{{#each model.errors.base as |error|}}
<p class="error">{{error.message}}</p>
{{/each}}
<p>{{input value=model.name placeholder="Name" name="name"}}</p>
{{#each model.errors.name as |error|}}
<p class="error">{{error.message}}</p>
{{/each}}
<p><button>Update</button> or {{#link-to 'lines'}}Cancel{{/link-to}}</p>
</form>
<p>{{#link-to 'lines.new'}}New line{{/link-to}}</p>
<table>
<thead>
<th>id</th>
<th>name</th>
<th>&nbsp;</th>
</thead>
{{#each model as |line|}}
<tr>
<td>{{line.id}}</td>
<td>{{line.name}}</td>
<td>
{{#link-to 'lines.show' line}}view{{/link-to}}
{{#link-to 'lines.edit' line}}edit{{/link-to}}
{{#link-to 'lines.destroy' line}}destroy{{/link-to}}
</td>
</tr>
{{/each}}
</table>
<form {{action 'save' model on='submit'}}>
{{#each model.errors.base as |error|}}
<p class="error">{{error.message}}</p>
{{/each}}
<p>{{input value=model.name placeholder="Name" name="name"}}</p>
{{#each model.errors.name as |error|}}
<p class="error">{{error.message}}</p>
{{/each}}
<p><button>Create</button> or {{#link-to 'lines'}}Cancel{{/link-to}}</p>
</form>
<dl>
<dt>id</dt>
<dd>{{model.id}}</dd>
<dt>name</dt>
<dd>{{model.name}}</dd>
</dl>
<p>{{#link-to 'lines'}}Back to list{{/link-to}}</p>
@pablobm
Copy link
Author

pablobm commented Sep 15, 2017

@payneio - Used routes for simplicity, but there could be a case of using controllers instead. I'm not entirely sold either way, but would love to hear opinions.

Sorry for the late response!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment