Skip to content

Instantly share code, notes, and snippets.

@pablobm
Last active June 3, 2022 10:07
Show Gist options
  • Select an option

  • Save pablobm/e77a98e5f3c610953a82 to your computer and use it in GitHub Desktop.

Select an option

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)

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 must be 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 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

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');

Accessing the current model

The correct way to obtain the current model from the route (eg: from an action handler) is:

this.controller.get('model')

I have seen other options, but I don't like them:

  • this.currentModel: it's a private, undocumented API. Don't expect it to be there tomorrow
  • this.modelFor('foo.bar.baz'): un-DRY and inconvenient

Trigger save on form submit

It's tempting to associate the save action with clicking a button:

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

But then we cannot press Enter on the text field 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.

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 warp-drive-data/warp-drive#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:
    1. It's the same API used in the edit route, making it easier to refactor both into a single mixin
    2. It allows us to remove the conditional that checks for record.get('isNew'). In fact we can reduce that function to a one-liner: this.controller.get('model').rollbackAttributes();
  • Drawbacks:
    1. I feel it doesn't communicate its intent as well as other options, because is sounds like the attributes are restored (to blank), but the record is not deleted (when it actually is)
    2. It doesn't mirror Store#createRecord like Store#unloadRecord does

Validation errors on base

Remember that validation errors can be associated to a record's base, not only to its properties. You may want to check if there's anything to display 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: ':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'));
},
}
});
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'));
},
willTransition() {
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() {
const record = this.controller.get('model');
if (record.get('isNew')) {
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'}}cancel{{/link-to}}</p>
<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>
@nadnoslen
Copy link

Thank you, great summary!!!

@payneio
Copy link

payneio commented Nov 19, 2016

Hi... this is super useful. Thank you. Strange how it is so difficult to find CRUD examples on these frameworks.
Why did you choose to do the actions in the routes instead of using controllers?

@ldong
Copy link

ldong commented Jul 15, 2017

This is super useful for #Ember beginners. I do recommend other devs to read.
Besides, if you are curious about the server response format, you can checkout the test case of Ember Rest as reference: https://github.com/emberjs/data/blob/master/tests/integration/adapter/rest-adapter-test.js

@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