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 statehasRoute(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 instructionnavigateBack(): 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 urlactivate(options: Object)
- activate routerdeactivate()
- 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');
}
}