Create a Menu with Child Routes using Aurelia

Looking for a way to include your routes within child routers to dynamically create a menu with Aurelia?

Thursday July 28, 2016 - Permalink - Categories: aurelia, javascript, typescript

Update 2: I've created a plugin which takes care of everything for you. You can get it here: https://github.com/jagonalez/aurelia-navigation-menu

Update: There were some issues within the code. I have updated the post to address these issues. I have created a repo on github with the sample code. You can find it here

One of the nice things about Aurelia is how easy it is to create a navigational menu. Within a Router object is array of NavModels under the property navigation. Using this we can dynamically create a menu based on routes mapped to the router.

The navigation property only contains the routes which are specific to that Router, it does not include any of the child routes. If you want to include child routes within your navigation menu then you'll have to do it yourself.

The method we're discussing today will allow you to dynamically create the menu but it likely isn't the best way to do so performance wise. We are loading the viewModel for each route and determining any child routes within that viewModel.

We'll be using a modified version of the Aurelia TypeScript navigation skeleton. The examples are also written in TypeScript.

//app.ts
import { Router, RouterConfiguration, RouteConfig, NavModel } from 'aurelia-router';
import { relativeToFile } from 'aurelia-path';
import { CompositionEngine, CompositionContext } from 'aurelia-templating';
import { autoinject } from 'aurelia-framework';
import { Origin } from 'aurelia-metadata';

@autoinject()
export class App {
  public router: Router;

  constructor(private compositionEngine: CompositionEngine) { }

  configureRouter(config: RouterConfiguration, router: Router) {
    config.title = 'Child Route Menu Example';
    config.map([
      { route: ['', 'home'], name: 'home',  moduleId: 'home',  nav: true, title: 'Home' },
      { route: 'cats',       name: 'cats',  moduleId: 'cats',  nav: true, title: 'Cats' },
      { route: 'dogs',       name: 'dogs',  moduleId: 'dogs',  nav: true, title: 'Dogs' },
      { route: 'birds',      name: 'birds', moduleId: 'birds', nav: true, title: 'Birds' }
    ]);

    this.router = router;
  }
}

We've got routes for our home page, cats, dogs and birds. We've set nav to true so each of this routes will be in the navigation property in our router object. 

Our app.html file has the following HTML:

<!-- app.html -->
<template>
  <require from="nav-menu.html"></require>
  <nav-menu router.bind="router"></nav-menu>

  <div class="page-host">
    <router-view></router-view>
  </div>
</template>

The nav-menu.html file is such:

<!-- nav-menu.html -->
<template bindable="router">
  <ul>
    <li repeat.for="row of router.navigation" class="${row.isActive ? 'active' : ''}">
      <a href.bind="row.href">${row.title}</a>
    </li>
  </ul>
</template>

OK great! Above our router-view we'll see a simple list which contains links for Home, Cats, Dogs, and Birds. Now let's add in some child routes.

//Cats.ts

import {Router, RouterConfiguration} from 'aurelia-router';

export class Cats {
  configureRouter(config: RouterConfiguration, router: Router) {
    config.title = 'Cats';
    config.map([
      { route: 'care',   name: 'care',   moduleId: 'cats/care',   nav: true, title: 'Caring' },
      { route: 'breeds', name: 'breeds', moduleId: 'cats/breeds', nav: true, title: 'Breeds' },
      { route: 'toys',   name: 'toys',   moduleId: 'cats/toys',   nav: true, title: 'Toys' },
    ]);
  }
}

Let's assume for simplicity's sake that dogs and birds have the same routes that our Cats class does. Now within our App.ts we could create an object which has all the routes and child routes on it. Or we can create our own navigation array.

In order to create our own navigational array we'll need to loop through the router's navigtaion property and load each moduleId

To loop through the navigation array within our route we'll create a mapNavigation function:

//app.ts continuation

public mapNavigation(router: Router, config?: RouteConfig) {
  let promises = [];
  let c = config ? config : {route: null};
  router.navigation.forEach( nav => {
    if (c.route !== nav.config.route) {
      promises.push(this.mapNavigationItem(nav, router));
    } else {
      promises.push(Promise.resolve(nav));
    }

  })
  return Promise.all(promises)
}

With the mapNavigation we pass a router object and we can also pass an optional RouterConfig object. We'll then loop through the navigation array of the router. We have logic in there to ensure the route we're mapping isn't the same route that is specified in the optional config parameter. If we don't check for this then we could potientially have a route that would go on for infinity. Finally we resolve all the mappings and return an array with the navigational items.

Next we have the mapNavigationItem function:

// app.ts continuation

public mapNavigationItem(nav: NavModel, router: Router) {
  const config = <any>nav.config
  const navModel = nav

  if (config.moduleId) {
    const childContainer = router.container.createChild();
    const instruction = {
      viewModel: relativeToFile(config.moduleId, Origin.get(router.container.viewModel.constructor).moduleId),
      childContainer: childContainer,
      view: config.view || config.viewStrategy,
    };
    return this.compositionEngine.ensureViewModel(<any>instruction)
    .then((context: CompositionContext) => {
      if ('configureRouter' in context.viewModel) {
        const childRouter = new Router(childContainer, router.history)
        const childConfig = new RouterConfiguration()

        context.viewModel.configureRouter(childConfig, childRouter)
        childConfig.exportToRouter(childRouter)

        childRouter.navigation.forEach( nav => {
          nav.href = `${navModel.href}/${nav.config.href ? nav.config.href : nav.config.name}`
        })
        return this.mapNavigation(childRouter, config)
          .then(r => navModel.navigation = r)
          .then( () => navModel);
      }
      return navModel
    })
  }
  return Promise.resolve(navModel);
}

We check if there is a moduleId associated with the navigation item. If there is we load the viewModel using Aurelia's composition Engine and check if there's a configureRouter function. If there is a configureRouter function we create a new Router and RouterConfiguration objects and configure them with the viewModel. We have to set the href for each navigation item. Then we run mapNavigation on the childRouter which will start the process again.

Now we just need to call our mapNavigation function on the router. We can make the call within the activate() method of the app.ts viewModel, this will make sure our router object has been fully configured before we pass on the navigation array.

//app.ts continuation

attached() {
  return this.mapNavigation(this.router)
}

We don't technically need to set this.routes as the navigation array within the router should now contain a subroutes array with our navigation items. If you don't want to directly change the router object you could create a copy of the nav parameter in mapNavigationItem.

Now we need to change our HTML template to show the additional navigation items.

<!-- nav-menu.html -->
<template bindable="router">
  <ul>
    <li repeat.for="row of router.navigation" class="${row.isActive ? 'active' : ''}">
      <a href.bind="row.href">${row.title}</a>
      <require if.bind="row.navigation" from="nav-menu.html"></require>
      <nav-menu if.bind="row.navigation" router.bind="row"></nav-menu>
    </li>
  </ul>
</template>

You'll notice we're requiring nav-menu from within nav-menu. I haven't had any issues doing this but your milage may vary should something change in Aurelia. 

Now when we re-load our project we'll see a list of the routes that are mapped in our app.ts and we will also see a list of any child router routes!

If you have a few child routes that are simple pages this may not hinder performance too much. Remember we're loading each component within the router and any child components. When a component is loaded it's constructor() method will be called as well. This will also show any item which has nav set to true within it's routeConfig. 

comments powered by Disqus