Advanced routing

In Aurelia you can choose to dynamically route rather than pre-configuring all your routes up front.

Here's how you configure a router to do that:

router.configure(config => {
  config.mapUnknownRoutes(instruction => {
    //check instruction.fragment
    //set instruction.config.moduleId
  });
});

All you have to do is set the config.moduleId property and you are good to go. You can also return a promise from mapUnknownRoutes in order to asynchronously determine the destination.

The router also has an addRoute method that can be used to dynmically add a route later.

Loading routes from a config file

The aurelia-router-loader allows you to load routes from a JSON configuration file.

For a non-root view model, you can use it like this:

import { Router } from 'aurelia-router';
import { RouterLoader} from 'router-loader/router-loader';
import {Loader} from 'aurelia-loader';

@inject(Router)
class ChildViewModel {
    configureRouter(config, router) {
        this.router = router;

        const loader = new Loader();
        const routeLoader = new RouterLoader(loader, router);

        routeLoader.defineRoutes([
            './routes/main.json',
            './routes/admin.json'
        ])

        // router config
    }

    goSomeWhere() {
        this.router.navigate('somewhere');
    }
}

Obviously if you want to go down this path, you should define an abstract baseclass (or mixin) where you can put this logic for reuse across various routable view models ;)

import { RouterLoader} from 'router-loader/router-loader';
import {Loader} from 'aurelia-loader';

class RouteLoading {
  loadRoutes(routeFiles) {
      const loader = new Loader();
      const routeLoader = new RouterLoader(loader, this.router);

      routeLoader.defineRoutes(routeFiles)
  }
}

Then instead use the RouteLoading base class in a routable view model.

import { Router } from 'aurelia-router';

@inject(Router)
class ChildViewModel extends RouteLoading {
    configureRouter(config, router) {
      this.router = router;
      this.loadRoutes([
        './routes/main.json',
        './routes/admin.json'
      ])
    }
}

Route config decoration

The project for this recipe can be found in aurelia-routing-demo

Configuring a router to work gracefully with the Aurelia best practices app layout

The application is structured into a nested hierarchy as follows:

/pages
  - index.ts
  - index.html

  /account
    - index.ts
    - index.html

Normally you have to specify the exact path to each ViewModel moduleas follows:

[
  { route: '', moduleId: 'pages/index', title: 'Home', name: 'home', nav: true },
  { route: 'account',  moduleId: 'pages/account/index', title: 'Account', name: 'account', nav: true }
]

Looks kinda ugly (and redundant!) having to specify the index at the end each time. We would instead like to achieve the following, and have the router figure out how to resolve the moduleId.

[
  { route: '', moduleId: 'pages', title: 'Home', name: 'home', nav: true },
  { route: 'account',  moduleId: 'pages/account', title: 'Account', name: 'account', nav: true }
]

For now the Aurelia Router can only use its default module resolution strategy.

We thus want each route decorated with a custom navigationStrategy which adds the index at the end of each moduleId before passing it on to the router module resolution engine.

We have chosen to decorate all the routes in the router with a special settings option: {indexed: true}. To achieve this we have built a special decorateSettings function which we can use with map, mapping over each route.

const routes = short.map(decorateSettings({indexed: true}));

Alternatively we could modify the moduleId directly on the routes (however this kinda defeats the purpose?).

We ideally want the route moduleId to signal the navigation intent (navigate to pages/account or even just /account) leaving the details of module resolution to the Aurelia routing engine. It is an Id after all, not a path.

Adding a setting is metadata for the engine, so we keep things separate.

Simply decorate moduleId of each route.

function decorateModuleId(route) { ... }

You can also use createDecorator(transform, options = {}) in indexed.ts to transform moduleIds on all routes given a transform function with options. The options available are root and page. Root is prepended on the moduleId and page is a static override of the page name (by default the route.name) or a even function to create the page name from the route name.

routes.map(createDecorator(nestedModuleId, {root: 'pages', page: 'index'}));

This will transform account to pages/account/index. Without the page option: pages/account/account.

Improving the Routing engine

The Route config decoration section above sketched out some options for encoding some conventions on the router and having the configuration configured automatically to adhere to these conventions. However it seems a bit too cumbersome to do it via decoration on the route config level. Would be better to simply instruct the routing engine or the router to use a different convention on all routes. We will explore how to achieve this next, how you can cutomize and encode your own routing strategies and conventions on the router level.

The routing engine can be found in the router and template-routing modules.

To patch the routing engine for your own needs, you need to deep dive into the internals of the engine to understand what goes on. Please clone the above mentioned repos locally, then npm link each one.

Now create a new project app-custom-router via the CLI.

$ npm link aurelia-router
$ npm link aurelia-templating-router

This will make symbolic links from the modules in node_modules to your locally linked ones, which again link to your cloned repos. In short, your project now uses your local cloned repos for aurelia-router and aurelia-templating-router which you can debug and play around with.

Changing default routing strategy

The TemplatingRouteLoader loads a route via loadRoute.

templating-router/route-loader.js

import {inject} from 'aurelia-dependency-injection';
import {CompositionEngine} from 'aurelia-templating';
import {RouteLoader, Router} from 'aurelia-router';

@inject(CompositionEngine)
export class TemplatingRouteLoader extends RouteLoader {
  constructor(compositionEngine) {
    super();
    this.compositionEngine = compositionEngine;
  }
  ...
  loadRoute(router, config) {
    ...
  }
}

As you can see TemplatingRouteLoader extends RouteLoader which has a simple interface:

export class RouteLoader {
  loadRoute(router: any, config: any, navigationInstruction: any) {
    throw Error('Route loaders must implement "loadRoute(router, config, navigationInstruction)".');
  }
}

You could write your own MyTemplatingRouteLoader and have Aurelia use that instead.

The aurelia-templating-router is a standard plugin as you can see in aurelia-templating-router.js

function configure(config) {
  config
    .singleton(RouteLoader, TemplatingRouteLoader)
    .singleton(Router, AppRouter)
    .globalResources('./router-view', './route-href');

  config.container.registerAlias(Router, AppRouter);
}

export {
  TemplatingRouteLoader,
  RouterView,
  RouteHref,
  configure
};

Writing a TemplatingRouteLoader plugin

You could write your own plugin the same way.

Create a new folder my-templating-router and run npm init from inside. Then install aurelia-templating-router as a dependency as you want to build on top of and customize it.

npm install aurelia-templating-router --save

Create a file my-route-loader.ts with a class MyRouteLoader.

import { TemplatingRouteLoader } from 'aurelia-templating-router';

class MyRouteLoader extends TemplatingRouteLoader {
  // custom override functionality
}

In your index.ts file add the following code. Notice how .singleton(RouteLoader, MyTemplatingRouteLoader). registers MyTemplatingRouteLoader as the RouteLoader for the framework, essentially a singleton registry. Excellent!

import {Router, AppRouter, RouteLoader} from 'aurelia-router';
import {MyRouteLoader} from './my-route-loader';

function configure(config) {
  config
    .singleton(RouteLoader, MyRouteLoader)
}

export {
  MyRouteLoader
};

Then you just need to make Aurelia use your custom templating router plugin ;) Instead of using the standardConfiguration

export function configure(aurelia) {
   aurelia.use
   .standardConfiguration()

We tell Aurelia boostrapper exactly what to use...

export function configure(aurelia) {
   aurelia.use
   .defaultBindingLanguage()
   .defaultResources()
   .developmentLogging()
   .router()
   .history()
   .eventAggregator();

   // ...
}

The key here is the router() which if we look into framework-configuration adds the aurelia-templating-router plugin.

  router(): FrameworkConfiguration {
    return this._addNormalizedPlugin('aurelia-templating-router');
  }

We want to still keep the registrations for Router and the global resources for router-view and route-href.

    .singleton(Router, AppRouter)
    .globalResources('./router-view', './route-href');

  config.container.registerAlias(Router, AppRouter);

However we want to override the registration entry for RouteLoader.

To achieve this override we can add aurelia.use.plugin('my-templating-router') at the end of the configuration. This will effectively override the registry for RouteLoader with MyTemplatingRouteLoader. Perfect :)

export function configure(aurelia) {
   aurelia.use
   .defaultBindingLanguage()
   // ...
   .router()
   .eventAggregator();

   // Use my own templating route loader!!
   aurelia.use.plugin('my-templating-router');

   aurelia.start().then(() => aurelia.setRoot());
}

Now we can customize our MyRouteLoader to suit our needs :)

RouteLoader

If we look at TemplatingRouteLoader, we see that the default stragegy for loadRoute to find a VM module is defined for instruction.viewModel.

import {relativeToFile} from 'aurelia-path';

export class TemplatingRouteLoader extends RouteLoader {
  // ...

  loadRoute(router, config) {
    let childContainer = router.container.createChild();
    let instruction = {

      viewModel: relativeToFile(config.moduleId, Origin.get(router.container.viewModel.constructor).moduleId);
    // ...

The relative file path of the VM module is calculated from the route moduleId and the router parent (container) moduleId.

Origin.get(router.container.viewModel.constructor).moduleId finds the moduleId of the parent router viewModel.

The full path is thus calculated like this:

/<parent module id>/<route module id>

You could f.ex change this to relativeToFile(config.moduleId) to have it be the route moduleId only, or alternatively relativeToFile('index', config.moduleId); to have ./contacts be resolved to ./contacts/index.

However instead of changing strategy in the templating router, why not let each individual router have the option to implement its own strategy.

Putting the router in charge

Let's change our TemplatingRouteLoader to achieve this. We change the instruction.viewModel to viewModel: this.viewModelLocation(router, config)

  loadRoute(router, config) {
    let childContainer = router.container.createChild();
    let instruction = {
      viewModel: this.viewModelLocation(router, config),  // <--- CHANGED
      childContainer: childContainer,
      view: config.view || config.viewStrategy,
      router: router
    };

Now lets introduce the function viewModelLocation, where we first try to use the router delegate function with a fallback to the default strategy from before.

  viewModelLocation(router, config) {
    if (router.viewModelLocation) {
      return router.viewModelLocation(config);
    } else {
      return relativeToFile(config.moduleId, Origin.get(router.container.viewModel.constructor).moduleId);
    }
  }

Customizing the router

On the Router, we can then introduce the viewModelLocation delegate function which we set to use the standard resolution strategy by default as well.

Important: We must now import the function relativeToFile from aurelia-path.

router/router.js

import {Router} from 'aurelia-router';
import {relativeToFile} from 'aurelia-path';

export class MyRouter extend Router {
  ...

  viewModelLocation(config) {
      return relativeToFile(config.moduleId, Origin.get(this.container.viewModel.constructor).moduleId);
  }

Now for any viewModel with router we can instead inject the MyRouter singleton which provides a viewModelLocation we can use to define different VM location strategy, while still have access to the original Router with the default strategy only. Pure Awesomeness!

An "intelligent" child router

The child router by default resolves moduleId relative to the parent router. If we mount the child router in a nested folder, it is currently unaware of this fact. This is why a child router moduleId must be relative to the parent router location, not relative to its own location.

Example: nested parent/child router

/src
  app.ts // parent router
  /contacts
    index.ts // child router
    details.ts

Parent router mounting a nested Contacts child router at ./contacts/index

{ route: '/contacts',  moduleId: './contacts/index', name:'contacts', nav: true,   title: 'Contacts' }

Child router details route

{
  route: '/contacts/:id/details',
  moduleId: './contacts/details',
  name: 'contact-details', 
  nav: true, 
  href: 'contact/details' 
}

In this scenario we would ideally like the child router to know where it has been mounted so it's moduleId resolution is relative to its own location so that moduleId: './details' would resolve to ./contacts/details.

One strategy could be to add a root: option to the router and then resolve according to this root if present. Let's use join from aurelia-path to achieve this, ie. we join root on parentPath is root is present on the router: join(parentPath, this.root)

import {join, relativeToFile} from 'aurelia-path';

export class MyRouter extend Router {

  viewModelLocation(config) {
    let parentPath = Origin.get(this.container.viewModel.constructor).moduleId;
    parentPath = this.root ? join(parentPath, this.root) : parentPath;
    return relativeToFile(config.moduleId, parentPath);
  }
}

This naive solution has one potential downside as we scale the app. In case we want to move the router or change the folder name, we need to remember to update the root on the router. The child router should not need to know where it is mounted. This is the responsibility of the parent router. However curently, there is no way to distinguish a child router being mounted as it is just another VM from the Parent router's perspective. We need to add more metadata and for this we should use the settings object on the route:

settings: {router: true}

Parent router mounting the nested contacts router

{ route: '/contacts',  moduleId: './contacts/index', name:' contacts', nav: true,   title: 'Contacts', settings: {router: true} }

Then our viewModelLocation becomes a bit more complex. We can reference the parent router via this.container.viewModel. Then we add a method childRouters() to filter the routes that are routers themselves, then from this list we find the moduleId of the route that matches on the name of the child router.

  routeModuleId(name) {
    return routes.find(route => route.name === name);
  }

  childRouters() {
    return routes.filter(route => return route.settings.router === true);
  }

  viewModelLocation(config) {
    let parent = this.container.viewModel;
    let root = this.root;
    if (parent) {
      let route = parent.childRouters().routeModuleId(this.name);
      root = route.moduleId;
    }

    let parentPath = Origin.get(parent.constructor).moduleId;
    parentPath = this.root ? join(parentPath, this.root) : parentPath;
    return relativeToFile(config.moduleId, parentPath);
  }

For this infrastructure to work, all we need to do is to name our child router Contacts in contacts/index.ts:

@inject(MyRouter)
export class Contacts {
  name = 'contacts';

  //...
}

Now we are good to go!!

Nested routing paths

Let's imagine a senario where each user has his/her own list of contacts and we want to render a specific contact for a given user.

route: '/users/:userId/contacts/:contactId/details'

It would be nice if we could have the parent router take care of the users part and a child router take care of the contacts while playing nice together. Perhaps we could add to the settings of the child router mount configuration:

settings: {router: true, mountRoute: '/users/:userId'}

Now we want to have our child router decorate its user routes be automatically prepended with the rootRoute path, effectively join('/users/:userId', 'contacts/:contactId/details') and so on.

For this we can use a routes config decoration pattern directly on the router.

router.map(createRootPathDecorator(nestedModuleId, settings));

// joins route patterns of mounting point and self
// ie. /users/:userId + '/' + contacts/:contactId/details
function createNestedRoute(nestedRoute, mountRoute) {
  route.route = [mountRoute, nestedRoute.route].join('/');
}

export class MyRouter extend Router {

  get mountRoute() {
    let parent = this.container.viewModel;
    // access the parent router and find the
    let mountRoute = parent.childRouters().routeModuleId(this.name);
    return mountRoute ? mountRoute.settings.rootRoute : null;
  }

  createNestedRoutes() {
    let mountRoutePattern = this.mountRoute;
    if (!mountRoutePattern) {
      return;
    }
    this.routes.map(route => return createNestedRoute(route, mountRoutePattern));
  }

Now we should be able to mount the Contacts router on the Users route and have it reflect the users route pattern. We can also mount it on the root Application router and have it work "on its own". Flexibility and agility at its best :)

Router API

  • isRoot(): boolean is it the root router?
  • reset() - resets the router's internal state
  • hasRoute(name: string): boolean - find route by name (incl parent routers)
  • hasOwnRoute(name: string): boolean - find route by name (only this router)
  • updateTitle() - update document title using current navigation instruction
  • navigateBack(): void - Navigates back to the most recent location in history.

refreshNavigation(): void - Updates the navigation routes with hrefs relative to the current location

configure(callbackOrConfig: RouterConfiguration|((config: RouterConfiguration) => RouterConfiguration)): Promise<void> - configure the router

navigate(fragment: string, options?: any): boolean - navigate to location

navigateToRoute(route: string, params?: any, options?: any): boolean - navigate to location corresponding to the route and params

createChild(container?: Container): Router - Creates a child router of the current router.

addRoute(config: RouteConfig, navModel?: NavModel): void - Registers a new route with the router.

handleUnknownRoutes(config?: string|Function|RouteConfig): void - use a moduleId, a function that selects the moduleId or an alternative RouteConfig to use to find the module of the VM for an unknown route.

generate(name: string, params?: any, options?: any = {}): string - Generates a URL fragment matching the specified route pattern. Uses the aurelia-route-recognizer plugin ;)

createNavModel(config: RouteConfig): NavModel - Creates a NavModel for the specified route config.

AppRouter API

The AppRouter is the top level router for the app.

  • loadUrl(url): Promise<NavigationInstruction> - loads the url
  • activate(options: Object) - activate router
  • deactivate() - deactivate router

Router Configuration API

addPipelineStep(name: string, step: Function|PipelineStep): RouterConfiguration - Adds a step to be run during the Router's navigation pipeline.

addAuthorizeStep(step: Function|PipelineStep): RouterConfiguration

addPreActivateStep(step: Function|PipelineStep): RouterConfiguration

addPreRenderStep(step: Function|PipelineStep): RouterConfiguration

addPostRenderStep(step: Function|PipelineStep): RouterConfiguration

map(route: RouteConfig|RouteConfig[]): RouterConfiguration - Maps one or more routes to be registered with the router

mapRoute(config: RouteConfig): RouterConfiguration - Maps a single route to be registered with the router.

mapUnknownRoutes(config) - Registers an unknown route handler to be run when the URL fragment doesn't match any registered routes.

config - A string containing a moduleId to load, a RouteConfig, or a function that takes the NavigationInstruction and selects a moduleId to load.

exportToRouter(router: Router): void - Applies the current configuration to the specified Router.

More advanced routing recipes

We will now explore a few different common routing recipes.

  • Dynamic routes

Dynamic routes

dynamic.html

<template>
  <h2>Dynamic Route</h2>
  <button click.delegate="addDynamicRoute()">Add Dynamic Route</button>
</template>

dynamic.ts

import {inject} from "aurelia-framework"
import {Router, RouterConfiguration, RouteConfig} from "aurelia-router";

@inject(RouterConfiguration, Router)
export class Dynamic {
    public addedDynoViewRoute: boolean = false;
    router:Router;
    config: RouterConfiguration;

    constructor(config: RouterConfiguration, router: Router) {
        this.config = config;
        this.router = router;
    }

    addDynamicRoute() {
        let newRoute: RouteConfig = {
            route: 'dyno-view',
            name: 'dyno-view',
            moduleId: 'views/dyno-view',
            nav: true,
            title: 'dyno-view'
        };

        this.theRouter.addRoute( newRoute );
        this.theRouter.refreshNavigation();
        this.theRouter.navigateToRoute('dyno-view');
    }
}

results matching ""

    No results matching ""