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.
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:
- This CRUD assumes that each action will take place in a different page/view/route
- Records will be persisted to the server using Ember Data. The adapter/serializer parts are supposed to be working and are not relevant
- Validation will happen server-side
- The interface will accomodate for the possibility of validation errors
This code could use mixins and components to avoid repetition. However I am avoiding this because:
- I want this as a simple, readable example with minimum complexity
- 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.
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.
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
(themodel()
hook in routes) expects this name (see "Themodel()
hook" below). - It is necessary anyway when working with nested models. Eg:
/receipt/:receipt_id/lines/:line_id
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');
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.
Following on the previous point, you may not need to provide an explicit
show
route:
- Ember will provide implicit routes for any routes mentioned in
app/router.js
- 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 Ember.Route
has an implementation of themodel()
hook, which will be used in an empty route
Remember that you will still need to provide a template.
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.
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 callingthis._super(ctrl, model)
and wonder why your app doesn't work. The second is that you may then dothis.model = model
and overwrite themodel()
function, breaking the route in later invocations. There's a similar risk if you overwritethis.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.
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.
DOM events, such as click
or submit
(among others) should be
captured using "classic" actions, as opposed to "closure" actions.
Example follows:
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:
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.
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:
- Edit a record
- Use the list to navigate away to another record
- 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.
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
.
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 itsModel#errors
populated. -
Model#destroyRecord
: same asdeleteRecord
, but also tries to persist the deletion of this actually-not-persisted record. For this, it makes a request toDELETE /{model-name}/{id}
but, since there's no id yet, it ends up beingDELETE /{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')
.
Using Store#createRecord
on the new
route has two gotchas:
-
As seen above, we have to remember to undo the changes if we cancel (ie: transition away without saving)
-
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:
-
Validation errors will not be added to the buffer object, and therefore won't be rendered on the form.
-
On error, the record will have been added to the store anyway, and it will appear on the list after transitioning away.
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
.
Resources I have found or been pointed to. I'm currently going through them:
Thank you, great summary!!!