Models¶
In the previous tutorial we hard-coded the various bits of data we displayed in our application. What we will do now is to define so-called “model” objects and then use those to create, read, update, and delete data from a remote system.
The first thing we need for that is to tell the ember application where to fetch the data from, by creating an adapter, which connects our application to the remote server:
$ yarn ember generate adapter application
Update the generated app/adapters/application.js
so that it looks like this:
import JSONAPIAdapter from '@ember-data/adapter/json-api';
export default class ApplicationAdapter extends JSONAPIAdapter {
host = 'https://mht.uzi.uni-halle.de';
namespace = 'client-seitige-web-anwendungen/api/example';
}
As you can see, our adapter is an extension of the DS.JSONAPIAdapter
. JSON API is a specification for how to build web APIs (Application Programming Interfaces), covering both how to structure the data (and errors) and what URLs and request methods to use. As Ember provides an adapter, all we need to do is configure it and it will automatically translate between the models that we create in our application and the JSON API representation.
We will update this configuration in the next tutorial to add authentication, but for the moment we only have two configuration settings. The host
specifies the server on which the API is running and the namespace
the path on the server at which the API can be found. The JSONAPIAdapter
will in practice combine the two to create the full URL from which data will be fetched.
Now that we have the adapter, we can use it to create some data on the server. We will start by implementing the user registration functionality. To do that we will first need to create a model for our user:
$ yarn ember generate model user
Initially the model is empty, as obviously the generator cannot know what data we want to put into our model. Update the model to add four properties:
import Model, { attr } from '@ember-data/model';
export default class UserModel extends Model {
@attr email;
@attr first_name;
@attr last_name;
@attr role;
}
Note the addition to the import statement. The properties are relatively self-explanatory, except for the role
, which we will use to distinguish teacher / student. Using the @attr()
decorator it is also possible to specify more detailed data types, but in general this is not necessary. You could also declare relationships here like hasMany
which have to be imported as well.
With the model in place, we can now use it to support the user registration. For this we will first need a new route:
$ yarn ember generate route users/register
Next, we want to use a component again to handle the user input. Thus create this component with component class by running
$ yarn ember generate component register-user -gc
Up to this point when we have generated a new route, we have simply typed the route URL into the browser and navigated to the page in that way. This is obviously not very usable and we really want the user to be able to click on a link an take us to another route. Update the app/components/nav-bar.hbs
so that the aria-label="User"
HTML looks like this:
<ul class="navbar-nav horizontal" aria-label="User" role="menu">
<li class="nav-item">
<LinkTo @route="users.register" class="nav-link" tabindex="0">Register</LinkTo>
</li>
<li class="nav-item">
<LinkTo @route="login" class="nav-link" role="menuitem" tabindex="-1">Login</LinkTo>
</li>
</ul>
Here we see a new Ember helper, the LinkTo
component, which allows us to easily create links to another route. One parameter of the LinkTo
component is the name of the route that the link should take us to. If you look at the code that generated our new route, we specified “users/register”. This is the route’s default path and the route’s name is derived from this by replacing all “/” with “.”. Thus to navigate to this new route, we specify users.register
as the route name. Try it out and you will see that you end up on an empty page, because obviously we have not yet added any content to the route’s template. Do that now and add the following code to the app/templates/users/register.hbs
:
and the following code to app/components/register-user.hbs
:
You are already familiar with the concepts used here, the only thing that is slightly new is the use of the onchange={{action "setNewRole" value="target.value"}}
. As with other actions that we have used so far, this attaches an action to the element, in this case it attaches it to the “onchange” event. Normally on the component class side, we would get the action event as the parameter for our action. However, here we make use of a little trick to simplify handling on the component class side. By specifying value="target.value"
we tell Ember that the value we want as a parameter for the action handler in the component class is the value
property of the target
property of the event. This is the actually selected value, so as you will see below, in the action handler, we can just use the value, without having to worry about getting it out of the event. If you look at your page, you will see that it is not rendered correctly, in fact, the app has crashed. This happened due to the fact that we reference an action that is defined nowhere.
Let’s fix this issue and actually handle these events in app/components/register-user.js
:
import Component from '@glimmer/component';
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
export default class UsersRegisterComponent extends Component {
@tracked newEmail = '';
@tracked newFirstName = '';
@tracked newLastName = '';
@tracked newRole = 'student';
@tracked errorMail = '';
@tracked errorFirstName = '';
@tracked errorLastName = '';
@tracked errorRole = '';
classLabel = 'text-danger';
classInput = 'is-invalid';
classSpan = 'invalid-feedback';
@action
createUser(event) {
event.preventDefault();
this.errorMail = '';
this.errorFirstName = '';
this.errorLastName = '';
this.errorRole = '';
}
@action
setNewRole(value) {
this.newRole = value;
}
}
Here you can see that in the setNewRole
action handler, we can simply set the value the user has selected into the newRole
property.
The createUser
handler does not do much, except clear all errors. If you do not implement error clearing via keypress events as in the previous tutorial, then this is generally a good idea.
The next step is to create our new user object:
import Component from '@glimmer/component';
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from '@ember/service';
export default class UsersRegisterComponent extends Component {
@service store;
@tracked newEmail = '';
@tracked newFirstName = '';
@tracked newLastName = '';
@tracked newRole = 'student';
@tracked errorMail = '';
@tracked errorFirstName = '';
@tracked errorLastName = '';
@tracked errorRole = '';
classLabel = 'text-danger';
classInput = 'is-invalid';
classSpan = 'invalid-feedback';
@action
createUser(event) {
event.preventDefault();
this.errorMail = '';
this.errorFirstName = '';
this.errorLastName = '';
this.errorRole = '';
let newUser = this.store.createRecord('user', {
email: this.newEmail,
first_name: this.newFirstName,
last_name: this.newLastName,
role: this.newRole
});
}
@action
setNewRole(value) {
this.newRole = value;
}
}
Notice the new import statement and the weird new decorater @service store
? We’ll cover this after we finished creating the register component. For now, you just need to know that we can access our application’s store via this injection that we aliased as service
when we imported it. The core interactions with models, creating and finding, both happen through the this.store
property. This store acts as an intermediary between the models, the adapters, and any caching that is enabled. Here we use the createRecord
method to create a new instance of the “user” model. The second parameter to createRecord
is an object with the model’s initial properties and values.
What is important here is that creating an instance like this does not automatically store the instance on the server. We need to explicitly call the save()
function on the new model:
import Component from '@glimmer/component';
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from '@ember/service';
export default class UsersRegisterComponent extends Component {
@service store;
@tracked newEmail = '';
@tracked newFirstName = '';
@tracked newLastName = '';
@tracked newRole = 'student';
@tracked errorMail = '';
@tracked errorFirstName = '';
@tracked errorLastName = '';
@tracked errorRole = '';
classLabel = 'text-danger';
classInput = 'is-invalid';
classSpan = 'invalid-feedback';
@action
createUser(event) {
event.preventDefault();
this.errorMail = '';
this.errorFirstName = '';
this.errorLastName = '';
this.errorRole = '';
let newUser = this.store.createRecord('user', {
email: this.newEmail,
first_name: this.newFirstName,
last_name: this.newLastName,
role: this.newRole
});
newUser.save();
}
@action
setNewRole(value) {
this.newRole = value;
}
}
The save()
function call is asynchronous and returns immediately, even though at this point the data will not yet have been sent to the server. Thus save()
cannot return the response from the server, instead it returns a Promise
. A promise is an object to which we can attach callback functions that are executed when the promise either succeeds or fails. We will start by implementing the success callback:
import Component from '@glimmer/component';
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from '@ember/service';
export default class UsersRegisterComponent extends Component {
@service store;
@service router;
@tracked newEmail = '';
@tracked newFirstName = '';
@tracked newLastName = '';
@tracked newRole = 'student';
@tracked errorMail = '';
@tracked errorFirstName = '';
@tracked errorLastName = '';
@tracked errorRole = '';
classLabel = 'text-danger';
classInput = 'is-invalid';
classSpan = 'invalid-feedback';
@action
createUser(event) {
event.preventDefault();
this.errorMail = '';
this.errorFirstName = '';
this.errorLastName = '';
this.errorRole = '';
let newUser = this.store.createRecord('user', {
email: this.newEmail,
first_name: this.newFirstName,
last_name: this.newLastName,
role: this.newRole
});
newUser.save().then((user) => {
this.router.transitionTo('modules')
});
}
@action
setNewRole(value) {
this.newRole = value;
}
}
The success callback is defined as a function that is passed to the then()
function on the promise. It has a single parameter, which is the newly stored user instance. Here we don’t need to do anything with that, we simply call the transitionTo
function on the router we injected earlier to switch to the “modules” route.
You should now try this out. If you provide all the necessary registration information and after clicking on “Register” are automatically taken to the list of modules, then your code works. If not, check the developer console to see if there are any errors and fix those.
While we can do certain validity checking on the client side, we always have to check on the server side that data received from the client is valid. If it is invalid, then the server will send an error and we need to implement this, by adding a catch
callback function:
import Component from '@glimmer/component';
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from '@ember/service';
export default class UsersRegisterComponent extends Component {
/**
* services
*/
@service store;
@service router;
/**
* tracked properties
*/
@tracked newEmail = '';
@tracked newFirstName = '';
@tracked newLastName = '';
@tracked newRole = 'student';
@tracked errorMail = '';
@tracked errorFirstName = '';
@tracked errorLastName = '';
@tracked errorRole = '';
/**
* untracked properties
*/
classLabel = 'text-danger';
classInput = 'is-invalid';
classSpan = 'invalid-feedback';
/**
* Resets all the error messages and stores a new
* user that is saved to the store and transitions
* to modules route.
* Catches errors should they occur
*/
@action
createUser(event) {
event.preventDefault();
this.errorMail = '';
this.errorFirstName = '';
this.errorLastName = '';
this.errorRole = '';
let newUser = this.store.createRecord('user', {
email: this.newEmail,
first_name: this.newFirstName,
last_name: this.newLastName,
role: this.newRole
});
newUser.save().then((user) => {
this.router.transitionTo('modules')
}).catch((response) => {
response.errors.forEach((error) => {
if(error.source.pointer == '/data/attributes/email') {
this.errorMail = error.detail;
} else if(error.source.pointer == '/data/attributes/first-name') {
this.errorFirstName = error.detail;
} else if(error.source.pointer == '/data/attributes/last-name') {
this.errorLastName = error.detail;
} else if(error.source.pointer == '/data/attributes/role') {
this.errorRole = error.detail;
}
})
})
}
/**
* Sets the new role if it changes
*/
@action
setNewRole(value) {
this.newRole = value;
}
}
The callback function we pass to the catch()
function has a single parameter, which is the response object. In the case of JSON API, the response object has an errors
property, which contains a list of all server-side errors. Each server-side error itself has a detail
property with a human-readable error message and a source.pointer
property that specifies where in the original submission the error was. The source.pointer
is always a path from the root to the incorrect data attribute in the submission, which is why when we test it, we need to include the full path.
By looping over all errors that have been sent by the server, we can update the error message for those fields that the user needs to correct. You can try this out by trying to register with an empty e-mail address or name.
Before we have a look on service injection next, we need to finish the application’s main navigation bar.
We already changed the a
tags on the right side to LinkTo
elements.
Update the first ul
to this:
Now, we can proceed to service injection.