Objectives

Incorporate aurelia routers into donation-client. This will enable a more orderly evolution of the application, allowing new viewmodels to be introduced in a consistent manner.

Lab Aurelia 2 Exercise 1: Login Feature in DonationService

Implement in DonationService a login feature, which checks the username/password against the fixtures we have already loaded in this class. Only allow the user to progress to logged in status if there is a valid entry

Solution

First incorporate some users into the fixtures:

app/services/fixtures.js

  users = {
    'homer@simpson.com': {
      firstName: 'Homer',
      lastName: 'Simpson',
      email: 'homer@simpson.com',
      password: 'secret'
    },
    'marge@simpson.com': {
      firstName: 'Marge',
      lastName: 'Simpson',
      email: 'marge@simpson.com',
      password: 'secret'
    }
  }

.. and load them in our service:

...
export default class DonationService {

  ...
  users = [];  
  ...

  constructor(data, ea) {
    this.users = data.users;
    ...
  }
  ...

Introduce this new method in DonationService:

src/services/donation-service.js

  login(email, password) {
    const status = {
      success: false,
      message: ''
    };

    if (this.users[email]) {
      if (this.users[email].password === password) {
        status.success = true;
        status.message = 'logged in';
      } else {
        status.message = 'Incorrect password';
      }
    } else {
      status.message = 'Unknown user';
    }

    return status;
  }

app.js can now be revised to use this method. This involves injecting the donation-service into the App, and refactoring login method:

src/app.js

import {inject} from 'aurelia-framework';
import DonationService from './services/donation-service';

@inject(DonationService)
export class App {

  email = 'marge@simpson.com';
  password = 'secret';

  loggedIn = false;

  constructor(ds) {
    this.donationService = ds;
  }

  login(e) {
    console.log(`Trying to log in ${this.email}`);
    const status = this.donationService.login(this.email, this.password);
    this.prompt = status.message;
    this.loggedIn = status.success;
  }

  logout() {
    console.log('Logging out`');
    this.loggedIn = false;
  }
}

Run the app now, and experiment with various valid and invalid credentials. Notice how the validation error messages are displayed.

Lab Aurelia 2 Exercise 2: Signup UI + Feature

Implement a signup viewmodel, which will add a user to the DonationService users array. This view should be visible when the user presses a 'signup' option on the menu. This will take some work, as we want either the login view OR the signup view to appear. Subsequently, once logged in, we wish neither view to be visible.

src/viewmodels/signup.html

<template>
  <form submit.delegate="register($event)" class="ui stacked segment form">
    <h3 class="ui header">Register</h3>
    <div class="two fields">
      <div class="field">
        <label>First Name</label>
        <input placeholder="First Name" type="text" value.bind="firstName">
      </div>
      <div class="field">
        <label>Last Name</label>
        <input placeholder="Last Name" type="text" value.bind="lastName">
      </div>
    </div>
    <div class="field">
      <label>Email</label>
      <input placeholder="Email" type="text" value.bind="email">
    </div>
    <div class="field">
      <label>Password</label>
      <input type="password" value.bind="password">
    </div>
    <button class="ui blue submit button">Submit</button>
  </form>
</template>

src/services/donation-service.js

  register(firstName, lastName, email, password) {
    const newUser = {
      firstName: firstName,
      lastName: lastName,
      email: email,
      password: password
    };
    this.users[email] = newUser;
  }

src/app.html

<template>

  <div class="ui container">

    <nav class="ui inverted menu">
      <header class="header item"><a href="/"> Donation </a></header>
      <div class="right menu">
        <div show.bind="!loggedIn">
          <a class="item" click.trigger="signup()"> Signup </a>
        </div>
        <div show.bind="loggedIn">
          <a class="item" click.trigger="logout()"> Logout </a>
        </div>
      </div>
    </nav>

    <section class="ui four column stackable grid basic segment">
      <div show.bind="!loggedIn" class="ui row">
        <div show.bind="!showSignup" class="ui row">
          <section class="ui five wide column">
            <compose view="./viewmodels/login.html"></compose>
          </section>
        </div>
        <div show.bind="showSignup" class="ui row">
          <section class="ui five wide column">
            <compose view="./viewmodels/signup.html"></compose>
          </section>
        </div>
      </div>
      <div show.bind="loggedIn" class="ui row">
        <aside class="column">
          <compose view-model="./viewmodels/donate"></compose>
        </aside>
        <article class="column">
          <compose view-model="./viewmodels/report"></compose>
        </article>
        <article class="column">
          <compose view-model="./viewmodels/candidates"></compose>
        </article>
        <article class="column">
          <compose view-model="./viewmodels/stats"></compose>
        </article>
      </div>
    </section>
  </div>

</template>

src/app.js

import {inject} from 'aurelia-framework';
import DonationService from './services/donation-service';

@inject(DonationService)
export class App {

  firstName = 'Marge';
  lastName = 'Simpson';
  email = 'marge@simpson.com';
  password = 'secret';

  loggedIn = false;
  showSignup = false;

  constructor(ds) {
    this.donationService = ds;
  }

  signup() {
    this.showSignup = true;
  }

  register(e) {
    this.showSignup = false;
    this.donationService.register(this.firstName, this.lastName, this.email, this.password);
  }

  login(e) {
    console.log(`Trying to log in ${this.email}`);
    const status = this.donationService.login(this.email, this.password);
    this.prompt = status.message;
    this.loggedIn = status.success;
  }

  logout() {
    console.log('Logging out`');
    this.loggedIn = false;
  }
}

Viewmodels

We will now restructure the viewmodels folder, encapsulating each view/viewmodel in its own directory like this:

The logged in element in app.html will need to reflect the changed paths:

    ...
      <div show.bind="loggedIn" class="ui row">
        <aside class="column">
          <compose view-model="./viewmodels/donate/donate"></compose>
        </aside>
        <article class="column">
          <compose view-model="./viewmodels/report/report"></compose>
        </article>
        <article class="column">
          <compose view-model="./viewmodels/candidates/candidates"></compose>
        </article>
        <article class="column">
          <compose view-model="./viewmodels/stats/stats"></compose>
        </article>
      </div>
    ...

Verify that everything works before proceeding to the next steps (make sure the import paths within each viewmodel are adjusted appropriately)

Notice that signup and login are views without viewmodels. Before we bring in these, introduce this new message class:

src/services/messages.js

...

export class LoginStatus {
  constructor(status) {
    this.status = status;
  }
}

Now bring in the viewmodels for login & signup:

src/viewmodels/login/login.js

import {inject} from 'aurelia-framework';
import {EventAggregator} from 'aurelia-event-aggregator';
import DonationService from '../../services/donation-service';
import {LoginStatus} from '../../services/messages';

@inject(EventAggregator, DonationService)
export class Login {

  email = 'marge@simpson.com';
  password = 'secret';

  constructor(ea, ds) {
    this.ea = ea;
    this.donationService = ds;
    this.prompt = '';
  }

  login(e) {
    console.log(`Trying to log in ${this.email}`);
    const status = this.donationService.login(this.email, this.password);
    this.ea.publish(new LoginStatus(status));
  }
}

src/viewmodels/signup/signup.js

import {inject} from 'aurelia-framework';
import {EventAggregator} from 'aurelia-event-aggregator';
import DonationService from '../../services/donation-service';
import {LoginStatus} from '../../services/messages';

@inject(EventAggregator, DonationService)
export class Signup {

  firstName = 'Marge';
  lastName = 'Simpson';
  email = 'marge@simpson.com';
  password = 'secret';

  constructor(ea, ds) {
    this.ea = ea;
    this.donationService = ds;
  }

  register(e) {
    this.showSignup = false;
    this.donationService.register(this.firstName, this.lastName, this.email, this.password);
    const status = this.donationService.login(this.email, this.password);
    this.ea.publish(new LoginStatus(status));
  }
}

And this is the revised app viewmodel + view:

/src/app.js

import {inject} from 'aurelia-framework';
import {EventAggregator} from 'aurelia-event-aggregator';
import DonationService from './services/donation-service';
import {LoginStatus} from './services/messages';

@inject(EventAggregator, DonationService)
export class App {

  loggedIn = false;
  showSignup = false;

  constructor(ea, ds) {
    this.donationService = ds;
    ea.subscribe(LoginStatus, msg => {
      this.loggedIn = msg.status.success;
    });
  }

  signup() {
    this.showSignup = true;
  }

  logout() {
    console.log('Logging out`');
    this.loggedIn = false;
  }
}

src/app.html

<template>

  <div class="ui container">

    <nav class="ui inverted menu">
      <header class="header item"><a href="/"> Donation </a></header>
      <div class="right menu">
        <div show.bind="!loggedIn">
          <a class="item" click.trigger="signup()"> Signup </a>
        </div>
        <div show.bind="loggedIn">
          <a class="item" click.trigger="logout()"> Logout </a>
        </div>
      </div>
    </nav>

    <section class="ui four column stackable grid basic segment">
      <div show.bind="!loggedIn" class="ui row">
        <div show.bind="!showSignup" class="ui row">
          <section class="ui five wide column">
            <compose view-model="./viewmodels/login/login"></compose>
          </section>
        </div>
        <div show.bind="showSignup" class="ui row">
          <section class="ui five wide column">
            <compose view-model="./viewmodels/signup/signup"></compose>
          </section>
        </div>
      </div>
      <div show.bind="loggedIn" class="ui row">
        <aside class="column">
          <compose view-model="./viewmodels/donate/donate"></compose>
        </aside>
        <article class="column">
          <compose view-model="./viewmodels/report/report"></compose>
        </article>
        <article class="column">
          <compose view-model="./viewmodels/candidates/candidates"></compose>
        </article>
        <article class="column">
          <compose view-model="./viewmodels/stats/stats"></compose>
        </article>
      </div>
    </section>
  </div>

</template>

Get all this working now. Study carefully the login, app and signup viewmodels. Notice how they are communicating via the event aggregator - and the message LoginStatus. See if you can follow the flow control. Remember, you can place breakpoints in the source (via Chrome developer tools) and follow this more closely.

EventAggregation

Currently, our use of the EventAggregator is a little haphazard. This is a revised version of the donation-service object:

src/services/donation-service.js

import {inject} from 'aurelia-framework';
import Fixtures from './fixtures';
import {TotalUpdate, LoginStatus} from './messages';
import {EventAggregator} from 'aurelia-event-aggregator';

@inject(Fixtures, EventAggregator)
export default class DonationService {

  donations = [];
  methods = [];
  candidates = [];
  users = [];
  total = 0;

  constructor(data, ea) {
    this.users = data.users;
    this.donations = data.donations;
    this.candidates = data.candidates;
    this.methods = data.methods;
    this.ea = ea;
  }

  donate(amount, method, candidate) {
    const donation = {
      amount: amount,
      method: method,
      candidate: candidate
    };
    this.donations.push(donation);
    console.log(amount + ' donated to ' + candidate.firstName + ' ' + candidate.lastName + ': ' + method);

    this.total = this.total + parseInt(amount, 10);
    console.log('Total so far ' + this.total);
    this.ea.publish(new TotalUpdate(this.total));
  }

  addCandidate(firstName, lastName, office) {
    const candidate = {
      firstName: firstName,
      lastName: lastName,
      office: office
    };
    this.candidates.push(candidate);
  }

  register(firstName, lastName, email, password) {
    const newUser = {
      firstName: firstName,
      lastName: lastName,
      email: email,
      password: password
    };
    this.users[email] = newUser;
  }

  login(email, password) {
    const status = {
      success: false,
      message: ''
    };

    if (this.users[email]) {
      if (this.users[email].password === password) {
        status.success = true;
        status.message = 'logged in';
      } else {
        status.message = 'Incorrect password';
      }
    } else {
      status.message = 'Unknown user';
    }
    this.ea.publish(new LoginStatus(status));
  }

  logout() {
    const status = {
      success: false,
      message: ''
    };
    this.ea.publish(new LoginStatus(status));
  }
}

It has the following small updates:

  • the login method no longer returns a success object - but publish as equivalent LoginStatus object on the event system

  • a new logout methods, which also publishes an appropriate event.

Login and Signup viewmodels can be updated and simplified:

src/viewmodels/login/login.js

import {inject} from 'aurelia-framework';
import DonationService from '../../services/donation-service';

@inject(DonationService)
export class Login {

  email = 'marge@simpson.com';
  password = 'secret';

  constructor(ds) {
    this.donationService = ds;
    this.prompt = '';
  }

  login(e) {
    console.log(`Trying to log in ${this.email}`);
    this.donationService.login(this.email, this.password);
  }
}

src/viewmodels/signup/signup.js

import {inject} from 'aurelia-framework';
import DonationService from '../../services/donation-service';

@inject(DonationService)
export class Signup {

  firstName = 'Marge';
  lastName = 'Simpson';
  email = 'marge@simpson.com';
  password = 'secret';

  constructor(ds) {
    this.donationService = ds;
  }

  register(e) {
    this.showSignup = false;
    this.donationService.register(this.firstName, this.lastName, this.email, this.password);
    this.donationService.login(this.email, this.password);
  }
}

With this simplification in place, we can move on to a revised approach to the application architecture.

Routers

We can significantly revise the approach we have taken to date - particularly the complex relationship between the various views the app viewmodel is displaying.

This is implemented via client side routers. These routes will be driven by a menu bar, not unlike our menus on the service rendered application.

First introduce the following menu bar template:

src/nav-bar.html

<template bindable="router">
  <nav class="ui inverted menu">
    <header class="header item"><a href="/"> Donation </a></header>
    <div class="right menu">
      <div repeat.for="row of router.navigation">
        <a class="${row.isActive ? 'active' : ''} item"  href.bind="row.href">${row.title}</a>
      </div>
    </div>
  </nav>
</template>

The App viewmodel can not be restructure as follows:

src/app.js

import {inject, Aurelia} from 'aurelia-framework';
import {EventAggregator} from 'aurelia-event-aggregator';
import {LoginStatus} from './services/messages';

@inject(Aurelia, EventAggregator)
export class App {

  constructor(au, ea) {
    ea.subscribe(LoginStatus, msg => {
      if (msg.status.success === true) {
        au.setRoot('home').then(() => {
          this.router.navigateToRoute('donate');
        });
      } else {
        au.setRoot('app').then(() => {
          this.router.navigateToRoute('login');
        });
      }
    });
  }

  configureRouter(config, router) {
    config.map([
      { route: ['', 'login'], name: 'login', moduleId: 'viewmodels/login/login', nav: true, title: 'Login' },
      { route: 'signup', name: 'signup', moduleId: 'viewmodels/signup/signup', nav: true, title: 'Signup' }
    ]);
    this.router = router;
  }
}

src/app.html

<template>
  <require from="nav-bar.html"></require>
  <div class="ui container page-host">
    <nav-bar router.bind="router"></nav-bar>
    <router-view></router-view>
  </div>
</template>

This viewmodel will trigger - on login - another router, manged my another view/viewmodel. This is it here:

src/home.js

import { inject, Aurelia } from 'aurelia-framework';

@inject(Aurelia)
export class Home {

  constructor(au) {
    this.aurelia = au;
  }

  configureRouter(config, router) {
    config.map([
      { route: ['', 'home'], name: 'donate', moduleId: 'viewmodels/donate/donate', nav: true, title: 'Donate' },
      { route: 'report', name: 'report', moduleId: 'viewmodels/report/report', nav: true, title: 'Report' },
      { route: 'candidates', name: 'candidates', moduleId: 'viewmodels/candidates/candidates', nav: true, title: 'Candidates' },
      { route: 'stats', name: 'stats', moduleId: 'viewmodels/stats/stats', nav: true, title: 'Stats' },
      { route: 'logout', name: 'logout', moduleId: 'viewmodels/logout/logout', nav: true, title: 'Logout' }
    ]);
    this.router = router;

    config.mapUnknownRoutes(instruction => {
      return 'home';
    });
  }
}

src/home.html

<template>
  <require from="nav-bar.html"></require>
  <div class="ui container page-host">
    <nav-bar router.bind="router"></nav-bar>
    <router-view></router-view>
  </div>
</template>

Run the app now - and see how it performs. In particular, pay close attention to the constructor of the app viewmodel:

  constructor(au, ea) {
    ea.subscribe(LoginStatus, msg => {
      if (msg.status.success === true) {
        au.setRoot('home').then(() => {
          this.router.navigateToRoute('donate');
        });
      } else {
        au.setRoot('app').then(() => {
          this.router.navigateToRoute('login');
        });
      }
    });
  }

Can you see what it going on here? Try to relate it to the login viewmodel behavior.

Logout

The routes we have introduced have a logout route defined. However we need matching view/viewmodels:

src/viewmodels/logout/logout.html

<template>

  <form submit.delegate="logout($event)" class="ui stacked segment form">
    <h3 class="ui header">Are you sure you want to log out?</h3>
    <button class="ui blue submit button">Logout</button>
  </form>

</template>

src/viewmodels/logout/logout.html

import DonationService from '../../services/donation-service';
import {inject} from 'aurelia-framework';

@inject(DonationService)
export class Logout {

  constructor(donationService) {
    this.donationService = donationService;
  }

  logout() {
    console.log('logging out');
    this.donationService.logout();
  }
}

All should work now - with one exception (see exercises).

Solution

Archive of the lab so far:

Exercise 1: Deployment

As an experiment, create a copy of the donation-client project, call it donation-client-experiment.

In the experiment version, delete all files except the following:

  • index.html
  • scripts/.

Open the index.html file directly in a browser - the app should behave as previously.

Create a new git repository for the experiment - and commit all sources (that remain afgter our delete step) to gh-pages branch. Push the repo as a new project to your github account. If everything goes according to plan, the app should be live with the url 'http://youraccount.github.com/donation-client-experiment' url

Examples:

The repository here is the experiment repo - note the branch name:

This is automatically published here:

Exercises 2: Stats Viewmodel

Run the app and look at the stats view. It seems to be perpetually set to 0. Why is this? Can you find a way of it displaying the correct view?

HINT: You should look at the Component Lifecycle documentation:

In particular, the attached event/method.

Exercise 3: Composite View/Viewmodel.

Build a new view/view model - to appear on the menu as 'dashboard' - which combines all existing views/viewmodels. This view model is visible in the app here: