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 must be 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 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
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 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 tomorrowthis.modelFor('foo.bar.baz'): un-DRY and inconvenient
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.
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:
- 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.
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 itsModel#errorspopulated.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}.
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.
It's possible to achieve the same effect using Model#rollbackAttributes:
- Advantages:
- It's the same API used in the
editroute, making it easier to refactor both into a single mixin - 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();
- It's the same API used in the
- Drawbacks:
- 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)
- It doesn't mirror
Store#createRecordlikeStore#unloadRecorddoes
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.
Resources I have found or been pointed to. I'm currently going through them:
Thank you, great summary!!!