Dynamic Routes

Up to this point we have basically been dealing with routes that show us specific functionality or a list of all elements. The next step is to define a route that allows us to load a specific data-point from the backend API. To do so, we first need to create a new route to view an individual module:

$ yarn ember generate route modules/view

Previously when we have created a new route, we have kept the default route definition. This time, we need to update it to include a dynamic component that we can then use to load a specific module from the backend. Open the file app/router.js, which will look something like this:

import EmberRouter from '@ember/routing/router';
import config from './config/environment';

const Router = EmberRouter.extend({
    location: config.locationType,
    rootURL: config.rootURL
});

Router.map(function() {
    this.route('login');
    this.route('modules', function() {
        this.route('view');
    });

    this.route('users', function() {
        this.route('register');
    });
});

export default Router;

The core route definition happens in the Router.map function call and you can see the nesting structure of the routes. What we need to do now is to update the definition of the view route to include a dynamic segment in the path:

Router.map(function() {
    this.route('login');
    this.route('modules', function() {
        this.route('view', {path: ':mid'});
    });

    this.route('users', function() {
        this.route('register');
    });
});

By default the URL path for the route is the same as the name of the route. However, we can override this by providing a configuration object with the explicit path. By using the ':mid' as the path definition, we define that this path segment is to match anything and that whatever it matches is to be stored under the key “mid” (we will see how to access that key in a second).

We can now use this new route definition with the dynamic segment in the app/components/modules-list.hbs to link each module in the list to an individual module view:

<td>
    <LinkTo @route="modules.view" @model={{module.id}}>{{module.code}}</LinkTo>
</td>
<td>
    <LinkTo @route="modules.view" @model={{module.id}}>{{module.name}}</LinkTo>
</td>

As you can see, we still use the LinkTo component, except that now in addition to the name of the route to navigate to, we also provide the id property of each module as a model parameter. This will be used in generate URL as the ':mid' segment. If you now click on the link, it will take you to the view for that route, which is currently empty. However, you will see that in the URL, the last part of the path now contains the identifier of the module.

We can now use that to fetch a single record from the backend. This works as with the list of records in the modules/index route. Update the app/routes/modules/view.js and update it so that it looks like this:

import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';

export default class ModulesViewRoute extends Route {
    @service store;
    model(params) {
        return this.store.findRecord('module', params.mid);
    }
}

Accessing the dynamic segment values works similarly to the filtering of the query. We get a params parameter to our model() function and this parameter has a property with the same name as the dynamic segment we defined in the router, in this case mid. We simply pass that to the findRecord function of the store and it will query the backend to retrieve a single module.

To see that this works, we also need to display the individual fields of the single module. Update the app/templates/modules/view.hbs to look like this:

{{page-title "View"}}
<ViewModule @module={{this.model}} />

Now we need to create this component by running

$ yarn ember generate component view-module

and then populate it with this content

<main class='col-lg-9'>
    <div class='row d-sm-flex justify-content-center'>
        <div class='col-6'>
            <h1>{{@module.code}} {{@module.name}}</h1>
            <dl>
                <dt>Semester</dt>
                <dd>{{@module.semester}}</dd>
                <dt>Contact</dt>
                <dd>{{@module.teacher.email}}</dd>
            </dl>
        </div>
    </div>
</main>

As with the LinkTo component, we can also use transition to dynamic routes from within the component. To do this, we will implement the necessary functionality to create a new module.

First we need to create a new route and component with class:

$ yarn ember generate route modules/new
$ yarn ember generate component -gc new-module

Next, we need to update the app/components/modules-navbar to add a link to the new route:

<li><a href="" tabindex="-1" role="menuitem">Enroll in Module</a></li>
<li>
    <LinkTo @route="modules.new">Create a new Module</LinkTo>
</li>

Then add the following into the app/templates/modules/new:

{{page-title "New"}}
<NewModule @model={{this.model}} />

and update app/components/new-model.hbs to:

<main class='row justify-content-center'>
    <div class='col-lg-6'>
        <h1>Create new Module</h1>
        <form {{on 'submit' this.createModule}}>
        <div class='form-group'>
            <label class={{if this.errorCode this.classLabel}}>Code
            <Input
                type='text'
                class='form-control {{if this.errorCode this.classInput}}'
                @value={{this.newCode}}
            />
            {{#if this.errorCode}}
                <span class={{this.classSpan}}>{{this.errorCode}}</span>
            {{/if}}
            </label>
            <label class={{if this.errorName this.classLabel}}>Name
            <Input
                type='text'
                class='form-control {{if this.errorName this.classInput}}'
                @value={{this.newName}}
            />
            {{#if this.errorName}}
                <span class={{this.classSpan}}>{{this.errorName}}</span>
            {{/if}}
            </label>
            <label class={{if this.errorSemester this.classLabel}}>Semester
            <select onchange={{action 'setNewSemester' value='target.value'}}>
                <option value='WS18/19'>Wintersemester 18/19</option>
                <option value='SS18'>Summersemester 18</option>
            </select>
            {{#if this.errorSemester}}
                <span class={{this.classSpan}}>{{this.errorSemester}}</span>
            {{/if}}
            </label>
        </div>
        <div class="btn-container">
            <LinkTo @route='modules' class='btn btn-secondary'>Don't create</LinkTo>
            <button type="submit" role="button">Create</button>
        </div>
        </form>
    </div>
</main>

Next, add the following code into the app/components/new-module.js:

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 NewModuleComponent extends Component {
    /**
    * Services
    */
    @service store;
    @service router;

    /**
    * Tracked Properties
    */
    @tracked newCode = '';
    @tracked newName = '';
    @tracked newSemester = 'WS18/19';
    @tracked errorCode = '';
    @tracked errorName = '';
    @tracked errorSemester = '';

    /**
    * Untracked Properties
    */
    classSpan = 'invalid-feedback';
    classLabel = 'text-danger';
    classInput = 'is-invalid';

    /**
    *
    * Handle submit action and create new module
    * Reset error messages
    * @param {*} event used to prevent default behavior
    */
    @action
    createModule(event) {
        event.preventDefault();
        let newModule = this.store.createRecord('module', {
            code: this.newCode,
            name: this.newName,
            semester: this.newSemester,
            teacher: this.args.model,
        });
        this.errorCode = '';
        this.errorName = '';
        this.errorSemester = '';
        newModule
            .save()
            .then((module) => {
                this.router.transitionTo('modules.view', module.id);
            })
            .catch((response) => {
                response.errors.forEach((error) => {
                if (error.source.pointer == '/data/attributes/code') {
                    this.errorCode = error.detail;
                } else if (error.source.pointer == '/data/attributes/name') {
                    this.errorName = error.detail;
                } else if (error.source.pointer == '/data/attributes/semester') {
                    this.errorSemester = error.detail;
                }
            });
        });
    }

    /**
    *
    * Sets value for semester on change
    * @param {String} value semester
    */
    @action
    setNewSemester(value) {
        this.newSemester = value;
    }
}

Two things are important in this code. First, as in our module model definition, the module is linked to a user via the teacher relationship. Thus to create a new module, we need to provide this as well and we use this.args.model to access it. In order for this to work, we actually need to load a user in the app/routes/modules/new.js:

import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';

export default class ModulesNewRoute extends Route {
    @service store;
    model() {
        return this.store.findRecord('user', 1);
    }
}

The other difference there is the use of this.router.transitionTo('modules.view', module.id) to transition to the individual modules view, again passing the id property of the newly created module.

If you try this now and go back to the list of all modules, you will see that your created module is listed among the others.