Skip to content

Instantly share code, notes, and snippets.

@silentworks
Forked from pablobm/README.md
Created April 2, 2016 15:50
Show Gist options
  • Save silentworks/6fd2ba085c9b4575d67124673044cb3b to your computer and use it in GitHub Desktop.
Save silentworks/6fd2ba085c9b4575d67124673044cb3b to your computer and use it in GitHub Desktop.
A clear convention for a CRUD with standard Ember + Ember Data

CRUD with Ember (+ Data)

Ember's official documentation describes a number of low-level APIs, but doesn't offer advice on 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 serve as a starting point for beginners, showing common 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).

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 must be accomodate for the possibility of validation errors

Implementation details

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 acion are the following:

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

willTransition

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.

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.

unloadRecord

To discard an newly created, unsaved record, use Store#unloadRecord. From the guides, it would appear that Model#deleteRecord and Model#destroyRecord might be a better bet. However, they have these problems:

  • Model#deleteRecord: it doesn't work when the record is unsaved but has errors. Ie: the user filled out the form, the app tried to save, server-side validation returned errors, the model had 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}.

This convention may change in the future, as the strange behaviour of deleteRecord is a bug, acknowledged at emberjs/data#4289 Still, I have a preference for Store#unloadRecord as it mirrors the previous Store#createRecord.

Other alternatives

It's possible to achieve the same effect using Model#rollbackAttributes:

  • Advantages: it's the same API used in the edit route, making it easier to refactor both into a single mixin
  • Drawbacks: it may not communicate its intent as well as other options, because is sounds like the attributes are restored, but the record is not deleted (when it actually is). Also, it doesn't mirror Store#createRecord either.

See also...

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

Please note

This is a work in progress.

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('index', {path: '/'});
this.route('new');
this.route('show', {path: ':id'});
this.route('edit', {path: ':id/edit'});
this.route('destroy', {path: ':id/destroy'});
});
});
export default Router;
import Ember from 'ember';
export default Ember.Route.extend({
model(params) {
this.store.find('line', params.id);
},
actions: {
confirm(record) {
record.destroyRecord()
.then(() => {
this.transitionTo('lines.index');
});
},
}
});
import Ember from 'ember';
export default Ember.Route.extend({
model(params) {
return this.store.find('line', params.id);
},
actions: {
save(record) {
record.save()
.then(() => {
this.transitionTo('lines.index');
});
},
willTransition() {
const record = this.controller.get('model');
return 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.index');
});
},
willTransition() {
const record = this.controller.get('model');
if (record.get('isNew')) {
return this.store.unloadRecord(record);
}
},
},
});
import Ember from 'ember';
export default Ember.Route.extend({
model(params) {
return this.store.findRecord('line', params.id);
},
}
<p>Are you sure?</p>
<p><button {{action 'confirm' model}}>Delete <strong>{{model.name}}</strong></button> or {{#link-to 'lines.index'}}cancel{{/link-to}}</p>
{{#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 {{action 'save' model}}>Update</button> or {{#link-to 'lines.index'}}Cancel{{/link-to}}</p>
<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>
{{#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 {{action 'save' model}}>Create</button> or {{#link-to 'lines.index'}}Cancel{{/link-to}}</p>
<dl>
<dt>id</dt>
<dd>{{model.id}}</dd>
<dt>name</dt>
<dd>{{model.name}}</dd>
</dl>
<p>{{#link-to 'lines.index'}}Back to list{{/link-to}}</p>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment