Application routing
Overview
@dojo/framework/routing
is a powerful set of tools to support declarative routing using a specialized widget that accepts a render property called an Outlet
and a widget that creates links with a href
generated from an outlet
id.
In this tutorial, we will start with a basic application with no routing. We will use Dojo’s declarative routing to configure some routes, use the Outlet
widget to define the view for each route and use the Link
widget to create links for the application’s outlets.
Prerequisites
You can open the tutorial on codesandbox.io or download the demo project and run npm install
to get started.
The @dojo/cli
command line tool should be installed globally. Refer to the Dojo local installation article for more information.
You also need to be familiar with TypeScript as Dojo uses it extensively.
Configuring the router
Create the routing configuration
All application routes needs to be to configured when creating the router instance, otherwise entering a route will not trigger a transition within the application. The RouteConfig
object is an object consisting of various properties:
path
- the URL path to match againstoutlet
- a unique identifier associated with a routedefaultParams
- default parameters are used as a fallback when parameters do not exist in the current routedefaultRoute
- a default route to be used if there is no other matching routechildren
- nested route configurations which can represent a nested path within a parent route
The route configuration should be static, i.e. not dynamically determined at runtime and defined as the default export of a module calledroutes.ts
in the project’ssrc
directory.
Application routing paths are assembled into a hierarchy based on the routing configuration. The children
property of a parent route accepts an array of more route configurations.
Include the required imports in the file main.ts
.
import { Registry } from '@dojo/framework/widget-core/Registry';
import { registerRouterInjector } from '@dojo/framework/routing/RouterInjector';
import routes from './routes';
Define the routing configuration in routes.ts
export default [
{
path: 'directory',
outlet: 'directory',
children: [
{
path: '{filter}',
outlet: 'filter'
}
]
},
{
path: 'new-worker',
outlet: 'new-worker'
},
{
path: '/',
outlet: 'home',
defaultRoute: true
}
];
Explanations for the route configuration in the above code block are explained earlier in this step.
Now, register the router in a registry
.
const registry = new Registry();
registerRouterInjector(routes, registry);
History Managers
The default history manager uses hash-based (fragment style) URLs. To use one of the other provided history managers pass it as the HistoryManager
in the third argument of registerRouterInjector
.
The registerRouterInjector
helper utility used in the code above is provided by @dojo/framework/routing
, and can be used to create a routing instance. The utility accepts:
- The application’s routing configuration
- A
registry
to define arouter
injector against - An object which specifies the history manager to be used
The utility returns therouter
instance if required.
Initialize the routing
To initialize the routing, pass the registry to the renderer’s mount
function.
r.mount({ domNode: document.querySelector('my-app') as HTMLElement, registry });
Next, we will create outlets
to control when our widgets are displayed.
Routing Outlets
Wrap the application widgets with the Outlet
widget.
Reminder
The path that is associated to an outlet name is defined by the routing configuration from the first section of this tutorial.
The Outlet
widget accepts two properties, id
and renderer
. The id
is the outlet
from the routing configuration and the renderer
is a function that returns widgets and nodes to display using v()
and w()
.
The renderer
function receives a MatchDetails
object that provides information about the route match.
router
: The router instance, which can be used to generate links.queryParams
: An object that contains any query params for the route.params
: An object that contains any path params for the route.type
: The type of match:index
: An exact matchpartial
: The route is a match but not exacterror
: The route is an error match
isError
: Helper function that returns true when the match type is errorisExact
: Helper function that returns true when the match type is exact
Consider anoutlet
configured for apath
ofabout
, the widget that it returns from therenderer
will render for a selected routeabout
(described as anindex
match). The widget will also display for any route that the outlet’spath
partially matches, for example,about/company
orabout/company/team
.
Warning: Make sure functions used in render property are bound!
Functions passed to v()
and w()
in a render property need to already have been bound to the correct context. This is because the render property is actually executed and returned from the Outlet
and therefore will be bound to the context of the Outlet
and not your widget.
Simply returning the widget or nodes that need to be displayed when an outlet has matched is usually all that is required, however there are scenarios where it is necessary to explicitly define a widget for an index
or error
match. This is where matchDetails
is beneficial. By using the information from matchDetails
, we can create simple logic to determine which widget to render for each scenario.
w(Outlet, {
id: 'outlet-name',
renderer: (matchDetails: MatchDetails) => {
if (matchDetails.isExact()) {
return w(MyIndexComponent, {});
} else if (matchDetails.isError()) {
return w(MyErrorComponent, {});
}
return w(MyComponent, {});
}
});
Import the Outlet
widget and MatchDetails
interface.
import { Outlet } from '@dojo/framework/routing/Outlet';
import { MatchDetails } from '@dojo/framework/routing/interfaces';
Replace Banner
with an Outlet
that returns Banner
.
w(Outlet, { id: 'home', renderer: () => {
return w(Banner, {});
}}),
Replace WorkerForm
with an Outlet
that returns WorkerForm
.
w(Outlet, { id: 'new-worker', renderer: () => {
return w(WorkerForm, {
formData: this._newWorker,
onFormInput: this._onFormInput,
onFormSave: this._addWorker
});
}}),
The filter
outlet use the match details information to determine if the filter
param should be passed to the WorkerContainer
widget.
Replace WorkerContainer
with an Outlet
that returns WorkerContainer
.
w(Outlet, { id: 'filter', renderer: (matchDetails: MatchDetails) => {
if (matchDetails.isExact()) {
return w(WorkerContainer, {
workerData: this._workerData,
filter: matchDetails.params.filter
});
}
return w(WorkerContainer, { workerData: this._workerData });
}})
])
Running the application now should display the Banner.ts
by default, but also enable routing to the other widgets using the /directory
and /new-worker
routes.
Next, we will add a side menu with links for the created outlets.
Adding Links
Add a sidebar menu to the application.
In this section we will be using the Link
widget, provided by @dojo/framework/routing
, to create link elements with an href
attribute for an outlet name. A label
for the Link
can be passed as children and parameter values for the outlet can be passed to a Link
widget using the params
property.
w(Link, { to: 'outlet-name', params: { paramName: 'value' } });
Add the Link
import in App.ts
.
import { Link } from '@dojo/framework/routing/Link';
Replace the render
function in App.ts
.
protected render() {
return v('div', [
v('div', { classes: this.theme(css.root) }, [
v('div', { classes: this.theme(css.container) }, [
v('h1', { classes: this.theme(css.title) }, [ 'Biz-E-Bodies' ]),
v('div', { classes: this.theme(css.links) }, [
w(Link, { key: 'home', to: 'home', classes: this.theme(css.link) }, [ 'Home' ]),
w(Link, { key: 'directory', to: 'directory', classes: this.theme(css.link) }, [ 'Worker Directory' ]),
w(Link, { key: 'newWorker', to: 'new-worker', classes: this.theme(css.link) }, [ 'New Worker' ])
])
])
]),
v('div', { classes: this.theme(css.main) }, [
w(Outlet, { id: 'home', renderer: () => {
return w(Banner, {});
}}),
w(Outlet, { id: 'new-worker', renderer: () => {
return w(WorkerForm, {
});
}}),
w(Outlet, { id: 'filter', renderer: (matchDetails: MatchDetails) => {
if (matchDetails.isExact()) {
return w(WorkerContainer, {
workerData: this._workerData,
filter: matchDetails.params.filter
});
}
return w(WorkerContainer, { workerData: this._workerData });
}})
])
]);
}
The updated block of code continues to render the banner, worker form and worker container widgets, and additionally renders three Link
widgets:
- A link to the home
- A link to the worker directory
- A link to the new worker form
Now, the links in the side menu can be used to navigate around the application!
Dynamic Outlet
Add filters to the WorkerContainer.ts
widget and create an outlet.
Finally, we are going to enhance the WorkerContainer.ts
with a filter on the workers’ last name. To do this we need to use the filter
outlet configured in the first section. The key difference for the filter
outlet is that the path is using a placeholder that indicates a path parameter, {filter}
.
URL matching in routes
The Dojo Routing documentation on GitHub further explains how outlets map to URLs.
This means a route with any value will match the filter
as long as the previous path segments match, so for the filter
outlet a route of directory/any-value-here
would be considered a match.
Add the new property to the WidgetContainerProperties
interface in WorkerContainer.ts
.
export interface WorkerContainerProperties {
workerData: WorkerProperties[];
filter?: string;
}
Include the Link
import
import { Link } from '@dojo/framework/routing/Link';
Add a private function to generate the filter links
private _createFilterLinks() {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const links = [];
for (let i = 0; i < alphabet.length; i++) {
const char = alphabet.charAt(i);
links.push(
v('span', { classes: this.theme(css.links) }, [
w(Link, { key: char, to: 'filter', params: { filter: char }}, [ char ])
])
);
}
return links;
}
Re-work the render function to filter using the new property
protected render() {
const {
workerData = [],
filter
} = this.properties;
const workers = workerData.filter((worker) => {
if (filter) {
return worker.lastName && worker.lastName.charAt(0).toUpperCase() === filter;
}
return true;
}).map((worker, i) => w(Worker, {
key: `worker-${i}`,
...worker
}));
return v('div', {}, [
v('h1', { classes: this.theme(css.title) }, [ 'Worker Directory' ]),
v('div', { classes: this.theme(css.filters) }, this._createFilterLinks()),
v('div', { classes: this.theme(css.container) }, workers)
]);
}
We have added a new property named filter
to WorkerContainerProperties
in WorkerContainer.ts
, which will be used to filter the workers based on their last name. When used by a normal widget this would be determined by its parent and passed in like any normal property. However for this application we need the route param value to be passed as the filter property. To achieve this, we can add a mapping function callback which receives an object argument consisting of four properties:
Previously, the raw Dojo widgets were rendered. Now, Outlets (which are also widgets) are rendered instead. These outlets ‘wrap’ the original widgets and pass-through parameters to the wrapped widget, as you define them in the Outlet callback function.
Summary
Dojo routing is a declarative, non-intrusive, mechanism to add complicated route logic to a web application. Importantly, by using a specialized widget with a render property, the widgets for the routes should not need to be updated and can remain solely responsible for their existing view logic.
If you would like, you can open the completed demo application on codesandbox.io or alternatively download the project.