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:

{{page-title "Register"}}
<RegisterUser />

and the following code to app/components/register-user.hbs:

<main class='row justify-content-center'>
    <form class='col-lg-6' {{on 'submit' this.createUser}}>
        <h1>Register</h1>
        <div class='form-group'>
        <label class='label {{if this.errorMail this.classLabel}}'>E-Mail Address
            <Input class='form-control {{if this.errorMail this.classInput}}' type='text' @value={{this.newEmail}} />
            {{#if this.errorMail}}
            <span class={{this.classSpan}}>{{this.errorMail}}</span>
            {{/if}}
        </label>
        </div>
        <div class='form-group'>

        <label class='label {{if this.errorFirstName 'text-danger'}}'>First Name
            <Input type='text' class='form-control {{if this.errorFirstName this.classInput}}' @value={{this.newFirstName}} />
            {{#if this.errorFirstName}}
            <span
                class={{this.classSpan}}
            >{{this.errorFirstName}}</span>
            {{/if}}
        </label>
        </div>
        <div class='form-group'>
        <label class='label {{if this.errorLastName 'text-danger'}}'>Last Name
            <Input type='text' class='form-control {{if this.errorLastName this.classInput}}' @value={{this.newLastName}} />
            {{#if this.errorLastName}}
            <span class={{this.classSpan}}>{{this.errorLastName}}</span>
            {{/if}}
        </label>
        </div>
        <div class='form-group'>
        <label class='label {{if this.errorRole 'text-danger'}}'>Role
            <select class={{if this.errorRole this.classInput}} onchange={{action 'setNewRole' value='target.value'}}>
            <option value='student'>Student</option>
            <option value='teacher'>Teacher</option>
            </select>
            {{#if this.errorRole}}
            <span class={{this.classSpan}}>{{this.errorRole}}</span>
            {{/if}}
        </label>
        </div>
        <div class='btn-container'>
        <button role='button' type='submit'>Register</button>
        </div>
    </form>
</main>

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:

<ul class='navbar-nav horizontal' role='menu' aria-label='Main'>
    <li class='navbar-brand'>Athena - Study Portal</li>
    <li><LinkTo @route='modules' role='menuitem' tabindex='0' class='nav-link'>My
        Modules</LinkTo></li>
    <li><a href='' role='menuitem' tabindex='-1' class='nav-link'>My Exams</a></li>
</ul>

Now, we can proceed to service injection.